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()
}