| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947 |
- import { chromium } from 'playwright'
- import assert from 'node:assert/strict'
- import { mkdir } from 'node:fs/promises'
- // 本地 H5 交互回归:所有 API 均由 fixture 拦截,不写真实业务库。
- const origin = process.env.CUSTOMER_TEST_ORIGIN || 'http://localhost:3006'
- const artifacts = '/tmp/shanxia-customer-e2e'
- await mkdir(artifacts, { recursive: true })
- const browser = await chromium.launch({ headless: true, channel: 'chrome' })
- const context = await browser.newContext({
- viewport: { width: 375, height: 812 },
- isMobile: true,
- deviceScaleFactor: 1
- })
- const page = await context.newPage()
- const pageErrors = []
- page.on('pageerror', (error) => pageErrors.push(error.message))
- const calls = []
- const customers = [
- {
- id: '90071992547409931',
- customerNo: 'KH001',
- customerName: '山峡化工测试客户',
- shortName: '山峡',
- customerType: 'company',
- level: 'normal',
- status: 'normal',
- dealStatus: 'undeal',
- mobile: '13800138000',
- source: 'phone',
- parkArea: '园区A',
- province: '湖北',
- city: '宜昌',
- district: '西陵',
- ownerUserId: 'user-1',
- ownerUserName: '测试用户',
- createBy: 'user-1',
- createTime: '2026-10-17 09:00:00',
- businessLicenseImages: '',
- businessPermitImages: '',
- otherCertImages: ''
- }
- ]
- const page2Customer = {
- ...customers[0],
- id: 'customer-page-2',
- customerNo: 'KH-P2',
- customerName: '分页客户'
- }
- const secondCustomer = {
- ...customers[0],
- id: 'customer-fast-b',
- customerNo: 'KH-B',
- customerName: '快速客户B',
- shortName: '客户B'
- }
- let contacts = [
- {
- id: 'contact-1',
- customerId: customers[0].id,
- contactName: '张经理',
- mobile: '13800138001',
- role: 'decision',
- status: 'active',
- isPrimary: 0,
- isKey: 1,
- createBy: 'user-1'
- }
- ]
- let follows = []
- let invoices = []
- const opportunity = {
- id: 'opp-1',
- opportunityName: '测试商机',
- customerId: customers[0].id,
- customerName: customers[0].customerName,
- amount: 123,
- status: 'following',
- stage: 'initial',
- stagePercent: 10,
- winRate: 30,
- ownerUserId: 'user-1',
- createBy: 'user-1'
- }
- let opportunityFollows = []
- let failInvoice = false
- let failList = false
- let failNextPage2 = false
- let delayNextPage2 = false
- let delayedDetailId = ''
- let forbiddenMode = ''
- let uploadGate = null
- let signalUploadStarted = null
- let uploadQueue = []
- const dict = Object.fromEntries(
- Object.entries({
- crm_customer_type: ['company', '企业'],
- crm_customer_level: ['normal', '普通'],
- crm_clue_source: ['phone', '电话'],
- crm_customer_park: ['园区A', '园区A'],
- crm_follow_type: ['phone', '电话'],
- crm_customer_status: ['normal', '正常'],
- crm_deal_status: ['undeal', '未成交'],
- crm_contact_role: ['decision', '决策人']
- }).map(([key, [value, label]]) => [key, [{ value, label, tagType: 'success' }]])
- )
- dict.crm_customer_type.push({ value: 'person', label: '个人', tagType: 'info' })
- dict.crm_customer_level.push({ value: 'key', label: '重点', tagType: 'warning' })
- dict.crm_opportunity_stage = [
- { value: 'initial', label: '初步' },
- { value: 'quote', label: '报价' }
- ]
- await context.addInitScript(() => {
- window.wx = {
- miniProgram: {
- navigateBack() {},
- reLaunch() {},
- getEnv(cb) {
- cb({ miniprogram: true })
- }
- }
- }
- })
- await context.route('**/api/**', async (route) => {
- const request = route.request()
- if (!new URL(request.url()).pathname.startsWith('/api/')) return route.continue()
- const path = new URL(request.url()).pathname.replace(/^\/api/, '')
- let body = {}
- try {
- body = request.postDataJSON() || {}
- } catch {
- body = {}
- }
- calls.push({ path, body })
- let data = {}
- let code = 200
- let msg = 'ok'
- if (path === '/web/auth/mp-webview-exchange') {
- const ticket = String(body.ticket || '')
- forbiddenMode = ticket.includes('forbidden-list')
- ? 'list'
- : ticket.includes('forbidden-detail')
- ? 'detail'
- : ''
- data = {
- token: 'fixture-token',
- refreshToken: 'fixture-refresh',
- userInfo: {
- id: 'user-1',
- userId: 'user-1',
- userName: '测试用户',
- role: 'admin',
- roles: ['admin']
- }
- }
- } else if (path === '/web/admin/dict-data/all') data = dict
- else if (path === '/web/auth/current-user')
- data = { id: 'user-1', userId: 'user-1', userName: '测试用户' }
- else if (path === '/web/auth/menu-codes') data = []
- else if (path === '/web/admin/menu-favorites') data = []
- else if (path === '/web/admin/users/options') data = [{ value: 'user-1', label: '测试用户' }]
- else if (/\/customer\/(list|mine|toFollow|key)$/.test(path)) {
- if (forbiddenMode === 'list') {
- code = 403
- msg = '无权限访问客户列表'
- } else if (failList) {
- code = 500
- msg = '列表测试失败'
- failList = false
- } else if (Number(body.page) === 2) {
- if (failNextPage2) {
- code = 500
- msg = '分页测试失败'
- failNextPage2 = false
- } else {
- if (delayNextPage2) {
- delayNextPage2 = false
- await new Promise((resolve) => setTimeout(resolve, 500))
- }
- data = { list: [page2Customer], total: customers.length + 1 }
- }
- } else data = { list: customers, total: customers.length + 1 }
- } else if (path === '/web/crm/customer/detail') {
- if (forbiddenMode === 'detail') {
- code = 403
- msg = '无权限访问客户详情'
- } else {
- if (body.id === delayedDetailId) await new Promise((resolve) => setTimeout(resolve, 500))
- data = [...customers, secondCustomer].find((c) => c.id === body.id)
- }
- } else if (path === '/web/crm/customer/checkDuplicate')
- data = { isDuplicate: false, duplicates: [] }
- else if (path === '/web/crm/customer/options') data = customers
- else if (path === '/web/crm/customer/column-facet-values')
- data = {
- list: body.keyword
- ? [{ value: 'KH-REMOTE-201', label: '第201号外值', count: 1 }]
- : [{ value: 'KH001', label: 'KH001', count: 1 }]
- }
- else if (path === '/web/crm/customer/add') {
- data = { ...body, id: 'created-customer', customerNo: 'KH002', createBy: 'user-1' }
- customers.push(data)
- } else if (path === '/web/crm/customer/update') {
- Object.assign(
- customers.find((c) => c.id === body.id),
- body
- )
- data = body
- } else if (path === '/web/crm/customer/delete' || path === '/web/crm/customer/intoPool') {
- for (let i = customers.length - 1; i >= 0; i--)
- if (body.ids.includes(customers[i].id)) customers.splice(i, 1)
- } else if (path === '/web/crm/contact/byCustomer') data = contacts
- else if (path === '/web/crm/contact/detail') data = contacts.find((c) => c.id === body.id)
- else if (path === '/web/crm/contact/add') {
- data = { ...body, id: 'contact-2', createBy: 'user-1' }
- contacts.push(data)
- } else if (path === '/web/crm/contact/update') {
- Object.assign(
- contacts.find((c) => c.id === body.id),
- body
- )
- data = body
- } else if (path === '/web/crm/contact/setPrimary')
- contacts.forEach((c) => (c.isPrimary = c.id === body.id ? 1 : 0))
- else if (path === '/web/crm/contact/delete')
- contacts = contacts.filter((c) => !body.ids.includes(c.id))
- else if (path === '/web/crm/customer/follow/byCustomer') data = follows
- else if (path === '/web/crm/customer/follow/add') {
- data = {
- ...body,
- id: 'follow-1',
- createBy: 'user-1',
- createByName: '测试用户',
- contactName: '张经理'
- }
- follows.push(data)
- } else if (path === '/web/crm/customer/follow/delete')
- follows = follows.filter((f) => !body.ids.includes(f.id))
- else if (path === '/web/crm/invoice-info/by-customer') data = invoices
- else if (path === '/web/crm/invoice-info/add') {
- if (failInvoice) {
- code = 500
- msg = '开票测试失败'
- failInvoice = false
- } else {
- data = { ...body, id: 'invoice-1', createBy: 'user-1' }
- invoices.push(data)
- }
- } else if (path === '/web/crm/invoice-info/update') {
- Object.assign(
- invoices.find((i) => i.id === body.id),
- body
- )
- data = body
- } else if (path === '/web/crm/invoice-info/set-default')
- invoices.forEach((i) => (i.isDefault = i.id === body.id ? '1' : '0'))
- else if (path === '/web/crm/invoice-info/delete')
- invoices = invoices.filter((i) => i.id !== body.id)
- else if (path === '/web/admin/attachment/upload') {
- signalUploadStarted?.()
- signalUploadStarted = null
- if (uploadGate) await uploadGate
- const queued = uploadQueue.shift()
- if (queued?.fail) {
- code = 500
- msg = queued.fail
- } else {
- data = queued?.data || {
- id: 'file-1',
- fileName: '证件.pdf',
- fileUrl: '/fixtures/customer.pdf',
- fileType: 'application/pdf'
- }
- }
- } else if (path === '/web/admin/attachment/preview-url') data = { previewUrl: body.fileUrl }
- else if (path === '/web/crm/opportunity/list')
- data = {
- list: [opportunity],
- total: 1
- }
- else if (path === '/web/crm/opportunity/detail') data = opportunity
- else if (path === '/web/crm/opportunity/update' || path === '/web/crm/opportunity/stage')
- Object.assign(opportunity, body)
- else if (path === '/web/crm/opportunity/follow/list')
- data = { list: opportunityFollows, total: opportunityFollows.length }
- else if (path === '/web/crm/opportunity/follow/add')
- opportunityFollows.push({ ...body, id: 'opp-follow-1', createBy: 'user-1' })
- else if (path === '/web/crm/opportunity/follow/update') Object.assign(opportunityFollows[0], body)
- else if (path === '/web/crm/opportunity/follow/delete') opportunityFollows = []
- else if (path.includes('/opportunity/')) data = { list: [] }
- else if (path === '/web/crm/contract/list')
- data = { rows: [{ id: 'contract-1', contractName: '测试合同' }], total: 1 }
- else if (path === '/web/monitor/operlog/list')
- data = {
- rows: [
- {
- id: 'log-1',
- title: 'CRM客户管理',
- operName: '测试用户',
- status: '0',
- operParam: '<img src=x onerror=alert(1)>',
- operTime: '2026-10-17 10:00:00'
- }
- ],
- total: 1
- }
- else throw new Error(`未处理的客户 E2E API:${path}`)
- await route.fulfill({
- contentType: 'application/json',
- body: JSON.stringify({ code, msg, data })
- })
- })
- const label = (name) => page.getByLabel(name, { exact: true }).filter({ visible: true })
- const click = (name) =>
- page
- .getByRole('button', { name, exact: true })
- .or(page.getByRole('tab', { name, exact: true }))
- .filter({ visible: true })
- .click()
- const waitHeading = (name) => page.getByRole('heading', { name, exact: true }).waitFor()
- const closeSheet = () =>
- page.getByRole('button', { name: '关闭', exact: true }).filter({ visible: true }).click()
- const hideDevTools = async () => {
- await page.addStyleTag({
- content:
- '#vue-devtools-anchor,.vue-devtools__anchor,.vue-devtools-frame{display:none!important}'
- })
- assert.equal(await page.locator('#vue-devtools-anchor').filter({ visible: true }).count(), 0)
- }
- const assertNoOverflow = async (label) => {
- const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)
- assert.equal(overflow, false, `${label}不应横向溢出`)
- }
- const assertMinTouchTargets = async (locator, label, minHeight = 44) => {
- const sizes = await locator.evaluateAll((elements) =>
- elements
- .filter((element) => element.getClientRects().length > 0)
- .map((element) => ({
- width: element.getBoundingClientRect().width,
- height: element.getBoundingClientRect().height
- }))
- )
- assert.ok(sizes.length > 0, `${label}应存在可见触控目标`)
- assert.ok(
- sizes.every(({ width, height }) => width >= 44 && height >= minHeight),
- `${label}触控目标应至少为44×${minHeight}px:${JSON.stringify(sizes)}`
- )
- }
- const waitForMessagesToClear = () =>
- page.waitForFunction(() => !document.querySelector('.el-message'), undefined, { timeout: 6000 })
- try {
- await page.goto(`${origin}/#/m/crm/customer?mpTicket=customer-forbidden-list`)
- await page.locator('.mp-customer-error').filter({ hasText: '无权限访问客户列表' }).waitFor()
- assert.equal(await page.locator('.mp-crm-expense-card').count(), 0)
- assert.equal(await page.locator('.mp-wv-status-tabs').count(), 0)
- assert.equal(await page.locator('.mp-wv-filter-bar--three').count(), 0)
- assert.equal(await page.locator('.mp-customer-summary').count(), 0)
- assert.equal(await page.locator('.mp-wv-speed-dial').count(), 0)
- assert.equal(await page.locator('.mp-customer-batch-bar').count(), 0)
- assert.equal(await page.locator('.mp-customer-sheet').count(), 0)
- await page.goto(
- `${origin}/#/m/crm/customer/${customers[0].id}?mpTicket=customer-forbidden-detail`
- )
- await page.locator('.mp-customer-error').filter({ hasText: '无权限访问客户详情' }).waitFor()
- await page.goto(`${origin}/#/m/crm/customer?mpTicket=customer-fixture`)
- await hideDevTools()
- await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).waitFor()
- const skeletonOrder = await page
- .locator('.mp-wv-header, .mp-wv-status-tabs, .mp-wv-filter-bar--three, .mp-crm-expense-card')
- .evaluateAll((nodes) => nodes.map((node) => node.className))
- assert.match(String(skeletonOrder[0]), /mp-wv-header/)
- assert.match(String(skeletonOrder[1]), /mp-wv-status-tabs/)
- assert.match(String(skeletonOrder[2]), /mp-wv-filter-bar--three/)
- assert.match(String(skeletonOrder[3]), /mp-crm-expense-card/)
- assert.equal(await page.locator('.mp-customer-actions').filter({ visible: true }).count(), 0)
- assert.equal(await page.locator('.mp-wv-speed-dial').count(), 1)
- assert.equal(await page.getByText('导出', { exact: true }).count(), 0)
- const cardActionSizes = await page
- .locator('.mp-crm-expense-card .op-actions button')
- .evaluateAll((buttons) =>
- buttons.map((button) => ({
- width: button.getBoundingClientRect().width,
- height: button.getBoundingClientRect().height
- }))
- )
- assert.ok(cardActionSizes.every(({ width, height }) => width >= 44 && height >= 28 && height <= 32))
- await assertMinTouchTargets(page.locator('.mp-wv-speed-main'), 'Speed Dial主按钮')
- await click('快捷操作')
- await assertMinTouchTargets(page.locator('.mp-wv-speed-item'), 'Speed Dial菜单', 30)
- await click('批量选择')
- await assertMinTouchTargets(page.locator('.mp-customer-batch-bar button'), '批量操作栏', 30)
- await page.locator('.expense-card__select').first().click()
- assert.equal(await page.locator('.mp-crm-expense-card .ec-bottom').count(), 0)
- failList = true
- await click('重点')
- await page.getByText('列表测试失败', { exact: true }).first().waitFor()
- assert.equal(await page.locator('.mp-crm-expense-card').count(), 0)
- assert.match(await page.locator('.mp-customer-summary').innerText(), /共 0 位客户 · 已加载 0 位/)
- assert.match(await page.locator('.mp-customer-batch-bar').innerText(), /已选 0 项/)
- await click('重试')
- await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).waitFor()
- await click('完成')
- await click('全部')
- failNextPage2 = true
- await click('加载更多(1/2)')
- await page.getByText('分页测试失败', { exact: true }).waitFor()
- await click('重试')
- await page.getByRole('button', { name: '查看客户:分页客户', exact: true }).waitFor()
- const page2Calls = calls.filter(
- (call) => call.path.endsWith('/customer/list') && Number(call.body.page) === 2
- )
- assert.deepEqual(
- page2Calls.slice(-2).map((call) => Number(call.body.page)),
- [2, 2],
- '分页失败后重试不得跳到第 3 页'
- )
- await page.getByLabel('搜索客户').fill('测试')
- await Promise.all([
- page.waitForResponse((response) => response.url().endsWith('/customer/list')),
- page.getByLabel('搜索客户').press('Enter')
- ])
- await Promise.all([
- page.waitForResponse((response) => response.url().endsWith('/customer/list')),
- page.getByLabel('搜索客户').fill('')
- ])
- assert.equal(
- calls.findLast((call) => call.path.endsWith('/customer/list')).body.keyword,
- undefined
- )
- delayNextPage2 = true
- await click('加载更多(1/2)')
- await click('客户类型')
- await click('企业')
- await page.waitForTimeout(650)
- assert.equal(
- await page.getByRole('button', { name: '查看客户:分页客户', exact: true }).count(),
- 0,
- '旧筛选的第 2 页结果不得混入新筛选'
- )
- await click('企业')
- await click('全部类型')
- for (const [name, endpoint] of [
- ['待跟进', 'toFollow'],
- ['重点', 'key'],
- ['全部', 'list']
- ]) {
- await Promise.all([
- page.waitForResponse((r) => r.url().endsWith('/customer/' + endpoint)),
- click(name)
- ])
- }
- await Promise.all([
- page.waitForResponse((r) => r.url().includes('/customer/mine')),
- click('我的')
- ])
- assert.equal(calls.findLast((c) => c.path.endsWith('/customer/mine')).body.viewMode, 'mine')
- await page.getByLabel('搜索客户').fill('顶部词')
- await Promise.all([
- page.waitForResponse((r) => r.url().includes('/customer/mine')),
- page.getByLabel('搜索客户').press('Enter')
- ])
- await page.getByRole('button', { name: /^更多/ }).click()
- const filterSheet = page.locator('.mp-sheet').filter({ hasText: '更多筛选' })
- await assertMinTouchTargets(
- filterSheet.locator('.mp-sheet-close, .mp-sheet-foot button'),
- '更多筛选底部与关闭按钮',
- 30
- )
- assert.equal(await filterSheet.getByLabel('关键词').inputValue(), '顶部词')
- assert.equal(await filterSheet.getByLabel('业务视图').inputValue(), 'mine')
- await filterSheet.getByLabel('关键词').fill('面板词')
- await filterSheet.getByLabel('业务视图').selectOption('key')
- await filterSheet.getByLabel('排序字段').selectOption('createTime')
- const mobileFilter = filterSheet
- .locator('details')
- .filter({ has: page.locator('summary:text-is("手机号")') })
- await mobileFilter.locator('summary').click()
- await mobileFilter.locator('input').fill('138')
- const followCountFilter = filterSheet
- .locator('details')
- .filter({ has: page.locator('summary:text-is("跟进次数")') })
- await followCountFilter.locator('summary').click()
- await followCountFilter.getByLabel('最小值').fill('1')
- const createTimeFilter = filterSheet
- .locator('details')
- .filter({ has: page.locator('summary:text-is("创建时间")') })
- await createTimeFilter.locator('summary').click()
- await createTimeFilter.getByLabel('起始时间').fill('2026-10-01T08:00')
- const customerNoFilter = filterSheet
- .locator('details')
- .filter({ has: page.locator('summary:text-is("客户编号")') })
- await customerNoFilter.locator('summary').click()
- await Promise.all([
- page.waitForResponse((r) => r.url().endsWith('/customer/column-facet-values')),
- customerNoFilter.locator('input[type="search"]').fill('远端')
- ])
- assert.equal(
- calls.findLast((c) => c.path.endsWith('/customer/column-facet-values')).body.keyword,
- '远端'
- )
- await customerNoFilter.getByText('第201号外值', { exact: true }).waitFor()
- await page.locator('summary').filter({ hasText: '客户类型' }).click()
- await page.getByLabel('企业', { exact: true }).check()
- await page.getByLabel('个人', { exact: true }).check()
- await page.locator('summary').filter({ hasText: '客户级别' }).click()
- await page.getByLabel('普通', { exact: true }).check()
- await page.getByLabel('重点', { exact: true }).check()
- await page.getByLabel('客户简称', { exact: true }).uncheck()
- await Promise.all([
- page.waitForResponse((r) => r.url().includes('/customer/key')),
- click('应用筛选')
- ])
- const appliedFilterCall = calls.findLast((c) => c.path.endsWith('/customer/key'))
- assert.equal(appliedFilterCall.body.keyword, '面板词')
- assert.equal(appliedFilterCall.body.customerTypeIn, 'company,person')
- assert.equal(appliedFilterCall.body.levelIn, 'normal,key')
- assert.equal(appliedFilterCall.body.mobile, '138')
- assert.equal(appliedFilterCall.body.followCountMin, '1')
- assert.equal(appliedFilterCall.body.beginTime, '2026-10-01 08:00:00')
- assert.equal(appliedFilterCall.body.sortField, 'createTime')
- assert.equal(await page.getByLabel('搜索客户').inputValue(), '面板词')
- assert.equal(
- await page.getByRole('button', { name: '重点', exact: true }).getAttribute('aria-pressed'),
- 'true'
- )
- assert.deepEqual(
- await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').allTextContents(),
- ['已选2项', '已选2项']
- )
- await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig').first().click()
- await click('企业')
- assert.equal(
- calls.findLast((c) => c.path.endsWith('/customer/key')).body.customerTypeIn,
- 'company'
- )
- assert.equal(
- await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').first().innerText(),
- '企业'
- )
- assert.equal(
- await page
- .locator('.mp-crm-expense-card')
- .first()
- .getByText('客户简称', { exact: true })
- .count(),
- 0
- )
- await page.getByRole('button', { name: /^更多/ }).click()
- await Promise.all([
- page.waitForResponse((r) => r.url().endsWith('/customer/list')),
- filterSheet.getByRole('button', { name: '重置', exact: true }).click()
- ])
- assert.equal(await page.getByLabel('搜索客户').inputValue(), '')
- assert.equal(
- await page.getByRole('button', { name: '全部', exact: true }).getAttribute('aria-pressed'),
- 'true'
- )
- assert.deepEqual(
- await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').allTextContents(),
- ['客户类型', '客户级别']
- )
- assert.equal(
- await page
- .locator('.mp-crm-expense-card')
- .first()
- .getByText('客户简称', { exact: true })
- .count(),
- 1
- )
- await page.waitForTimeout(3200)
- await page.setViewportSize({ width: 414, height: 812 })
- await hideDevTools()
- await assertNoOverflow('414px 有数据列表')
- await page.screenshot({ path: `${artifacts}/list-414.png`, fullPage: true })
- await page.setViewportSize({ width: 375, height: 812 })
- await assertNoOverflow('375px 有数据列表')
- await page.screenshot({ path: `${artifacts}/list-375.png`, fullPage: true })
- await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).click()
- await waitHeading('客户详情')
- await page.goto(`${origin}/#/m/crm/customer/${customers[0].id}`)
- await waitHeading('客户详情')
- await page.reload()
- await waitHeading('客户详情')
- customers[0].createBy = 'another-user'
- await page.reload()
- await waitHeading('客户详情')
- assert.equal(await page.getByRole('button', { name: '删除', exact: true }).isDisabled(), true)
- customers[0].createBy = 'user-1'
- await page.reload()
- await waitHeading('客户详情')
- assert.equal(await page.getByRole('button', { name: '删除', exact: true }).isEnabled(), true)
- const detailActionSizes = await page.locator('.mp-wv-fab-bar button').evaluateAll((buttons) =>
- buttons.map((button) => ({
- width: button.getBoundingClientRect().width,
- height: button.getBoundingClientRect().height
- }))
- )
- assert.ok(detailActionSizes.every(({ width, height }) => width >= 44 && height >= 28 && height <= 32))
- assert.deepEqual(await page.locator('.mp-customer-detail-tabs button').allTextContents(), [
- '资料',
- '跟进记录',
- '联系人',
- '商机',
- '合同',
- '开票信息',
- '操作日志'
- ])
- const detailTag = page.locator('.mp-customer-webview-tag').first()
- await detailTag.waitFor()
- assert.notEqual(
- await detailTag.evaluate((tag) => getComputedStyle(tag).color),
- 'rgb(255, 255, 255)'
- )
- await page.setViewportSize({ width: 414, height: 812 })
- await hideDevTools()
- await assertNoOverflow('414px 客户详情')
- await page.screenshot({ path: `${artifacts}/detail-414.png`, fullPage: true })
- await page.setViewportSize({ width: 375, height: 812 })
- await page.goto(`${origin}/#/m/crm/customer/${secondCustomer.id}?tab=invoice`)
- await page.getByText('暂无开票信息', { exact: true }).waitFor()
- assert.equal(
- calls.findLast((call) => call.path.endsWith('/customer/detail')).body.id,
- secondCustomer.id
- )
- assert.ok(
- await page
- .locator('.mp-customer-detail-tabs button')
- .filter({ hasText: '开票信息' })
- .evaluate((button) => button.classList.contains('active'))
- )
- delayedDetailId = customers[0].id
- const slowARequest = page.waitForRequest((request) => {
- if (!request.url().endsWith('/web/crm/customer/detail')) return false
- try {
- return request.postDataJSON()?.id === customers[0].id
- } catch {
- return false
- }
- })
- await page.evaluate((id) => {
- location.hash = `#/m/crm/customer/${id}?tab=follow`
- }, customers[0].id)
- await slowARequest
- await page.evaluate((id) => {
- location.hash = `#/m/crm/customer/${id}?tab=contact`
- }, secondCustomer.id)
- await page.getByRole('heading', { name: '张经理', exact: true }).waitFor()
- await page.waitForTimeout(650)
- assert.ok(
- await page
- .locator('.mp-customer-detail-tabs button')
- .filter({ hasText: '联系人' })
- .evaluate((button) => button.classList.contains('active'))
- )
- await click('资料')
- await page.getByRole('heading', { name: secondCustomer.customerName, exact: true }).waitFor()
- assert.equal(await page.getByRole('heading', { name: customers[0].customerName }).count(), 0)
- delayedDetailId = ''
- await page.evaluate((id) => {
- location.hash = `#/m/crm/customer/${id}?tab=follow`
- }, customers[0].id)
- await page.getByRole('button', { name: '+ 添加跟进', exact: true }).waitFor()
- assert.ok(
- await page
- .locator('.mp-customer-detail-tabs button')
- .filter({ hasText: '跟进记录' })
- .evaluate((button) => button.classList.contains('active'))
- )
- await click('跟进记录')
- await click('+ 添加跟进')
- await label('关联联系人').selectOption('contact-1')
- await label('跟进内容 *').fill('移动端跟进测试')
- await label('下次联系时间').fill('2026-10-18T10:00')
- await page
- .locator('.mp-sheet')
- .filter({ hasText: '添加跟进记录' })
- .locator('input[type=file]')
- .setInputFiles({
- name: '跟进附件.pdf',
- mimeType: 'application/pdf',
- buffer: Buffer.from('%PDF-1.4\nfollow-fixture')
- })
- await page.getByText('customer.pdf', { exact: true }).filter({ visible: true }).waitFor()
- await click('保存跟进')
- await page.getByText('移动端跟进测试', { exact: true }).waitFor()
- assert.equal(follows[0].customerId, '90071992547409931')
- assert.equal(follows[0].nextTime, '2026-10-18 10:00:00')
- assert.equal(follows[0].attachment, '/fixtures/customer.pdf')
- const followAttachment = page
- .locator('.mp-wv-card')
- .filter({ hasText: '移动端跟进测试' })
- .locator('.att-item')
- .first()
- const followAttachmentBox = await followAttachment.boundingBox()
- assert.equal(followAttachmentBox?.width, 72)
- assert.equal(followAttachmentBox?.height, 72)
- await followAttachment.locator('.att-preview').click()
- await page.getByRole('dialog').getByText('customer.pdf', { exact: true }).waitFor()
- await page.keyboard.press('Escape')
- await click('联系人')
- await page.getByRole('heading', { name: '张经理', exact: true }).waitFor()
- await click('+ 新增联系人')
- await label('联系人姓名 *').fill('李经理')
- await label('手机号 *').fill('13900139000')
- await click('保存联系人')
- await page.getByRole('heading', { name: '李经理', exact: true }).waitFor()
- assert.equal(contacts[1].customerId, '90071992547409931')
- await click('开票信息')
- await click('+ 新增开票信息')
- await label('单位名称(章) *').fill('测试开票单位')
- await label('税号').fill('123456789')
- await click('保存开票信息')
- await page.getByText('测试开票单位', { exact: true }).waitFor()
- await click('设为默认')
- await page.getByText('默认', { exact: true }).filter({ visible: true }).waitFor()
- assert.equal(invoices[0].isDefault, '1')
- await click('操作日志')
- await click('CRM客户管理')
- await page.getByText('<img src=x onerror=alert(1)>', { exact: true }).waitFor()
- assert.equal(await page.locator('img[src="x"]').count(), 0)
- await closeSheet()
- assert.equal(await page.locator('.mp-sheet-mask').filter({ visible: true }).count(), 0)
- await click('商机')
- await click('测试商机')
- await page.getByText('123', { exact: true }).waitFor()
- await click('修改商机')
- await label('商机名称 *').fill('移动修改商机')
- await click('保存商机操作')
- await page.getByText('移动修改商机', { exact: true }).filter({ visible: true }).first().waitFor()
- assert.equal(opportunity.customerId, '90071992547409931')
- await click('推进阶段')
- await label('商机阶段 *').selectOption('quote')
- await label('变更原因 *').fill('已报价')
- await click('保存商机操作')
- await page.getByRole('button', { name: '推进阶段', exact: true }).waitFor()
- assert.equal(opportunity.stagePercent, 60)
- await click('添加商机跟进')
- await label('商机跟进内容 *').fill('商机跟进测试')
- await click('保存商机操作')
- await page.getByText('商机跟进测试', { exact: true }).waitFor()
- await click('编辑商机跟进')
- await label('商机跟进内容 *').fill('商机跟进已编辑')
- await click('保存商机操作')
- await page.getByText('商机跟进已编辑', { exact: true }).waitFor()
- await click('删除商机跟进')
- await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
- await page.getByText('暂无跟进记录', { exact: true }).waitFor()
- await closeSheet()
- await click('合同')
- await page.getByText('测试合同', { exact: true }).waitFor()
- await click('资料')
- await page.screenshot({ path: `${artifacts}/detail-375.png`, fullPage: true })
- await click('编辑')
- await waitHeading('编辑客户')
- await label('客户简称 *').fill('移动编辑')
- const certificateInput = page.locator('input[type=file]').first()
- let releaseUploadGate
- uploadGate = new Promise((resolve) => {
- releaseUploadGate = resolve
- })
- const uploadStarted = new Promise((resolve) => {
- signalUploadStarted = resolve
- })
- const uploadCallsBeforeConcurrent = calls.filter(
- (call) => call.path === '/web/admin/attachment/upload'
- ).length
- await certificateInput.setInputFiles({
- name: '并发首个.pdf',
- mimeType: 'application/pdf',
- buffer: Buffer.from('%PDF-1.4\nconcurrent-first')
- })
- await uploadStarted
- await certificateInput.evaluate((input) => {
- const transfer = new DataTransfer()
- transfer.items.add(new File(['second'], '并发第二个.pdf', { type: 'application/pdf' }))
- Object.defineProperty(input, 'files', { value: transfer.files, configurable: true })
- input.dispatchEvent(new Event('change', { bubbles: true }))
- })
- await page.waitForTimeout(100)
- assert.equal(
- calls.filter((call) => call.path === '/web/admin/attachment/upload').length,
- uploadCallsBeforeConcurrent + 1,
- '上传期间再次选择不得启动第二个上传请求'
- )
- releaseUploadGate()
- uploadGate = null
- await page.getByText('customer.pdf', { exact: true }).waitFor()
- await page.waitForFunction(() => {
- const input = document.querySelector('input[type=file]')
- return !!input && !input.disabled
- })
- uploadQueue = [
- {
- data: {
- id: 'partial-ok',
- fileName: '部分成功.pdf',
- fileUrl: '/fixtures/partial-ok.pdf',
- fileType: 'application/pdf'
- }
- }
- ]
- const uploadCallsBeforePartialFailure = calls.filter(
- (call) => call.path === '/web/admin/attachment/upload'
- ).length
- await certificateInput.setInputFiles({
- name: '部分成功.pdf',
- mimeType: 'application/pdf',
- buffer: Buffer.from('%PDF-1.4\npartial-ok')
- })
- await page.getByText('partial-ok.pdf', { exact: true }).waitFor()
- await page.waitForFunction(() => {
- const input = document.querySelector('input[type=file]')
- return !!input && !input.disabled
- })
- uploadQueue = [{ fail: '后续上传失败' }]
- await certificateInput.setInputFiles({
- name: '后续失败.pdf',
- mimeType: 'application/pdf',
- buffer: Buffer.from('%PDF-1.4\npartial-fail')
- })
- for (
- let attempt = 0;
- attempt < 100 &&
- calls.filter((call) => call.path === '/web/admin/attachment/upload').length <
- uploadCallsBeforePartialFailure + 2;
- attempt += 1
- )
- await page.waitForTimeout(20)
- await page.getByText('后续上传失败', { exact: true }).last().waitFor()
- assert.equal(uploadQueue.length, 0, '后续失败请求应被处理')
- assert.equal(
- calls.filter((call) => call.path === '/web/admin/attachment/upload').length,
- uploadCallsBeforePartialFailure + 2
- )
- assert.ok(await page.getByText('customer.pdf', { exact: true }).isVisible())
- await assertMinTouchTargets(
- page.locator(
- '.mp-customer-attachments .att-preview, .mp-customer-attachments .att-remove, .mp-customer-attachments .att-add'
- ),
- '附件预览、删除与添加'
- )
- await waitForMessagesToClear()
- await page.setViewportSize({ width: 414, height: 812 })
- await hideDevTools()
- await assertNoOverflow('414px 附件表单')
- await page.screenshot({ path: `${artifacts}/form-attachments-414.png`, fullPage: true })
- await page.setViewportSize({ width: 375, height: 812 })
- await click('保存客户')
- await waitHeading('客户详情')
- assert.equal(customers[0].shortName, '移动编辑')
- assert.match(customers[0].businessLicenseImages, /\/fixtures\/customer\.pdf/)
- assert.match(customers[0].businessLicenseImages, /\/fixtures\/partial-ok\.pdf/)
- await page.getByRole('button', { name: '返回客户列表' }).click()
- await click('快捷操作')
- await click('新增客户')
- await waitHeading('新增客户')
- for (const [name, value] of [
- ['客户名称 *', '新客户'],
- ['客户简称 *', '新'],
- ['省份 *', '湖北'],
- ['城市 *', '宜昌'],
- ['区县 *', '西陵'],
- ['所在园区 *', '园区A']
- ])
- await label(name).fill(value)
- await label('客户类型 *').selectOption('company')
- await label('客户来源 *').selectOption('phone')
- await label('单位名称(章)').fill('新增开票单位')
- failInvoice = true
- await click('保存客户')
- await page.getByText('客户已创建。请补存开票资料,无需再次新增客户。').waitFor()
- await click('补存开票资料')
- await waitHeading('客户详情')
- assert.equal(calls.filter((c) => c.path === '/web/crm/customer/add').length, 1)
- await page.getByRole('button', { name: '返回客户列表' }).click()
- await page.getByRole('button', { name: '查看客户:新客户', exact: true }).waitFor()
- await click('快捷操作')
- await click('批量选择')
- await page
- .locator('.mp-crm-expense-card')
- .filter({ hasText: '新客户' })
- .locator('.expense-card__select')
- .click()
- await click('放入公海')
- await label('放入原因 *').fill('测试批量放入')
- await click('确认放入')
- await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
- await page.getByRole('button', { name: '查看客户:新客户', exact: true }).waitFor({
- state: 'hidden'
- })
- assert.deepEqual(calls.findLast((c) => c.path.endsWith('/customer/intoPool')).body.ids, [
- 'created-customer'
- ])
- await click('快捷操作')
- await click('批量选择')
- await page.locator('.expense-card__select').first().click()
- await click('删除')
- await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
- await page.getByText('暂无客户,可调整筛选后重试').waitFor()
- assert.deepEqual(calls.findLast((c) => c.path.endsWith('/customer/delete')).body.ids, [
- '90071992547409931'
- ])
- failList = true
- await page.getByLabel('搜索客户').fill('失败')
- await page.getByLabel('搜索客户').press('Enter')
- await page.getByText('列表测试失败', { exact: true }).first().waitFor()
- await click('重试')
- await page.getByText('暂无客户,可调整筛选后重试').waitFor()
- await waitForMessagesToClear()
- for (const width of [375, 414]) {
- await page.setViewportSize({ width, height: 812 })
- await assertNoOverflow(`${width}px 空列表`)
- assert.equal(await page.locator('.mp-sheet-mask').filter({ visible: true }).count(), 0)
- assert.equal(await page.locator('.mp-wv-speed-mask.open').count(), 0)
- if (width === 414)
- await page.screenshot({ path: `${artifacts}/list-empty-414.png`, fullPage: true })
- }
- assert.deepEqual(pageErrors, [])
- console.log(
- JSON.stringify({
- result: 'PASS',
- assertions:
- '四视图、快捷/高级筛选与显示字段、搜索清空、分页失败回滚与乱序隔离、卡片动作、直达详情刷新、创建人权限、客户跟进/联系人/商机/合同/开票/日志、72px附件上传预览、客户编辑与新增失败重试、批量公海/删除、44px触达、375/414无溢出遮罩',
- apiCalls: calls.length,
- screenshots: artifacts
- })
- )
- } catch (error) {
- await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true })
- console.error(await page.locator('body').innerText())
- console.error('Page errors:', pageErrors)
- console.error(
- 'API calls:',
- calls.map((c) => c.path)
- )
- throw error
- } finally {
- await context.close()
- await browser.close()
- }
|