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: '', 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('', { 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() }