Files
shanxia2.0/web-admin/scripts/test-mp-crm-customer.mjs
2026-10-19 18:28:02 +08:00

947 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => {
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 >= 44),
`${label}触控目标应至少为44×44px:${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 >= 44))
await assertMinTouchTargets(page.locator('.mp-wv-speed-main'), 'Speed Dial主按钮')
await click('快捷操作')
await assertMinTouchTargets(page.locator('.mp-wv-speed-item'), 'Speed Dial菜单')
await click('批量选择')
await assertMinTouchTargets(page.locator('.mp-customer-batch-bar button'), '批量操作栏')
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'),
'更多筛选底部与关闭按钮'
)
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 >= 44))
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()
}