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