test-mp-crm-customer.mjs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  1. import { chromium } from 'playwright'
  2. import assert from 'node:assert/strict'
  3. import { mkdir } from 'node:fs/promises'
  4. // 本地 H5 交互回归:所有 API 均由 fixture 拦截,不写真实业务库。
  5. const origin = process.env.CUSTOMER_TEST_ORIGIN || 'http://localhost:3006'
  6. const artifacts = '/tmp/shanxia-customer-e2e'
  7. await mkdir(artifacts, { recursive: true })
  8. const browser = await chromium.launch({ headless: true, channel: 'chrome' })
  9. const context = await browser.newContext({
  10. viewport: { width: 375, height: 812 },
  11. isMobile: true,
  12. deviceScaleFactor: 1
  13. })
  14. const page = await context.newPage()
  15. const pageErrors = []
  16. page.on('pageerror', (error) => pageErrors.push(error.message))
  17. const calls = []
  18. const customers = [
  19. {
  20. id: '90071992547409931',
  21. customerNo: 'KH001',
  22. customerName: '山峡化工测试客户',
  23. shortName: '山峡',
  24. customerType: 'company',
  25. level: 'normal',
  26. status: 'normal',
  27. dealStatus: 'undeal',
  28. mobile: '13800138000',
  29. source: 'phone',
  30. parkArea: '园区A',
  31. province: '湖北',
  32. city: '宜昌',
  33. district: '西陵',
  34. ownerUserId: 'user-1',
  35. ownerUserName: '测试用户',
  36. createBy: 'user-1',
  37. createTime: '2026-10-17 09:00:00',
  38. businessLicenseImages: '',
  39. businessPermitImages: '',
  40. otherCertImages: ''
  41. }
  42. ]
  43. const page2Customer = {
  44. ...customers[0],
  45. id: 'customer-page-2',
  46. customerNo: 'KH-P2',
  47. customerName: '分页客户'
  48. }
  49. const secondCustomer = {
  50. ...customers[0],
  51. id: 'customer-fast-b',
  52. customerNo: 'KH-B',
  53. customerName: '快速客户B',
  54. shortName: '客户B'
  55. }
  56. let contacts = [
  57. {
  58. id: 'contact-1',
  59. customerId: customers[0].id,
  60. contactName: '张经理',
  61. mobile: '13800138001',
  62. role: 'decision',
  63. status: 'active',
  64. isPrimary: 0,
  65. isKey: 1,
  66. createBy: 'user-1'
  67. }
  68. ]
  69. let follows = []
  70. let invoices = []
  71. const opportunity = {
  72. id: 'opp-1',
  73. opportunityName: '测试商机',
  74. customerId: customers[0].id,
  75. customerName: customers[0].customerName,
  76. amount: 123,
  77. status: 'following',
  78. stage: 'initial',
  79. stagePercent: 10,
  80. winRate: 30,
  81. ownerUserId: 'user-1',
  82. createBy: 'user-1'
  83. }
  84. let opportunityFollows = []
  85. let failInvoice = false
  86. let failList = false
  87. let failNextPage2 = false
  88. let delayNextPage2 = false
  89. let delayedDetailId = ''
  90. let forbiddenMode = ''
  91. let uploadGate = null
  92. let signalUploadStarted = null
  93. let uploadQueue = []
  94. const dict = Object.fromEntries(
  95. Object.entries({
  96. crm_customer_type: ['company', '企业'],
  97. crm_customer_level: ['normal', '普通'],
  98. crm_clue_source: ['phone', '电话'],
  99. crm_customer_park: ['园区A', '园区A'],
  100. crm_follow_type: ['phone', '电话'],
  101. crm_customer_status: ['normal', '正常'],
  102. crm_deal_status: ['undeal', '未成交'],
  103. crm_contact_role: ['decision', '决策人']
  104. }).map(([key, [value, label]]) => [key, [{ value, label, tagType: 'success' }]])
  105. )
  106. dict.crm_customer_type.push({ value: 'person', label: '个人', tagType: 'info' })
  107. dict.crm_customer_level.push({ value: 'key', label: '重点', tagType: 'warning' })
  108. dict.crm_opportunity_stage = [
  109. { value: 'initial', label: '初步' },
  110. { value: 'quote', label: '报价' }
  111. ]
  112. await context.addInitScript(() => {
  113. window.wx = {
  114. miniProgram: {
  115. navigateBack() {},
  116. reLaunch() {},
  117. getEnv(cb) {
  118. cb({ miniprogram: true })
  119. }
  120. }
  121. }
  122. })
  123. await context.route('**/api/**', async (route) => {
  124. const request = route.request()
  125. if (!new URL(request.url()).pathname.startsWith('/api/')) return route.continue()
  126. const path = new URL(request.url()).pathname.replace(/^\/api/, '')
  127. let body = {}
  128. try {
  129. body = request.postDataJSON() || {}
  130. } catch {
  131. body = {}
  132. }
  133. calls.push({ path, body })
  134. let data = {}
  135. let code = 200
  136. let msg = 'ok'
  137. if (path === '/web/auth/mp-webview-exchange') {
  138. const ticket = String(body.ticket || '')
  139. forbiddenMode = ticket.includes('forbidden-list')
  140. ? 'list'
  141. : ticket.includes('forbidden-detail')
  142. ? 'detail'
  143. : ''
  144. data = {
  145. token: 'fixture-token',
  146. refreshToken: 'fixture-refresh',
  147. userInfo: {
  148. id: 'user-1',
  149. userId: 'user-1',
  150. userName: '测试用户',
  151. role: 'admin',
  152. roles: ['admin']
  153. }
  154. }
  155. } else if (path === '/web/admin/dict-data/all') data = dict
  156. else if (path === '/web/auth/current-user')
  157. data = { id: 'user-1', userId: 'user-1', userName: '测试用户' }
  158. else if (path === '/web/auth/menu-codes') data = []
  159. else if (path === '/web/admin/menu-favorites') data = []
  160. else if (path === '/web/admin/users/options') data = [{ value: 'user-1', label: '测试用户' }]
  161. else if (/\/customer\/(list|mine|toFollow|key)$/.test(path)) {
  162. if (forbiddenMode === 'list') {
  163. code = 403
  164. msg = '无权限访问客户列表'
  165. } else if (failList) {
  166. code = 500
  167. msg = '列表测试失败'
  168. failList = false
  169. } else if (Number(body.page) === 2) {
  170. if (failNextPage2) {
  171. code = 500
  172. msg = '分页测试失败'
  173. failNextPage2 = false
  174. } else {
  175. if (delayNextPage2) {
  176. delayNextPage2 = false
  177. await new Promise((resolve) => setTimeout(resolve, 500))
  178. }
  179. data = { list: [page2Customer], total: customers.length + 1 }
  180. }
  181. } else data = { list: customers, total: customers.length + 1 }
  182. } else if (path === '/web/crm/customer/detail') {
  183. if (forbiddenMode === 'detail') {
  184. code = 403
  185. msg = '无权限访问客户详情'
  186. } else {
  187. if (body.id === delayedDetailId) await new Promise((resolve) => setTimeout(resolve, 500))
  188. data = [...customers, secondCustomer].find((c) => c.id === body.id)
  189. }
  190. } else if (path === '/web/crm/customer/checkDuplicate')
  191. data = { isDuplicate: false, duplicates: [] }
  192. else if (path === '/web/crm/customer/options') data = customers
  193. else if (path === '/web/crm/customer/column-facet-values')
  194. data = {
  195. list: body.keyword
  196. ? [{ value: 'KH-REMOTE-201', label: '第201号外值', count: 1 }]
  197. : [{ value: 'KH001', label: 'KH001', count: 1 }]
  198. }
  199. else if (path === '/web/crm/customer/add') {
  200. data = { ...body, id: 'created-customer', customerNo: 'KH002', createBy: 'user-1' }
  201. customers.push(data)
  202. } else if (path === '/web/crm/customer/update') {
  203. Object.assign(
  204. customers.find((c) => c.id === body.id),
  205. body
  206. )
  207. data = body
  208. } else if (path === '/web/crm/customer/delete' || path === '/web/crm/customer/intoPool') {
  209. for (let i = customers.length - 1; i >= 0; i--)
  210. if (body.ids.includes(customers[i].id)) customers.splice(i, 1)
  211. } else if (path === '/web/crm/contact/byCustomer') data = contacts
  212. else if (path === '/web/crm/contact/detail') data = contacts.find((c) => c.id === body.id)
  213. else if (path === '/web/crm/contact/add') {
  214. data = { ...body, id: 'contact-2', createBy: 'user-1' }
  215. contacts.push(data)
  216. } else if (path === '/web/crm/contact/update') {
  217. Object.assign(
  218. contacts.find((c) => c.id === body.id),
  219. body
  220. )
  221. data = body
  222. } else if (path === '/web/crm/contact/setPrimary')
  223. contacts.forEach((c) => (c.isPrimary = c.id === body.id ? 1 : 0))
  224. else if (path === '/web/crm/contact/delete')
  225. contacts = contacts.filter((c) => !body.ids.includes(c.id))
  226. else if (path === '/web/crm/customer/follow/byCustomer') data = follows
  227. else if (path === '/web/crm/customer/follow/add') {
  228. data = {
  229. ...body,
  230. id: 'follow-1',
  231. createBy: 'user-1',
  232. createByName: '测试用户',
  233. contactName: '张经理'
  234. }
  235. follows.push(data)
  236. } else if (path === '/web/crm/customer/follow/delete')
  237. follows = follows.filter((f) => !body.ids.includes(f.id))
  238. else if (path === '/web/crm/invoice-info/by-customer') data = invoices
  239. else if (path === '/web/crm/invoice-info/add') {
  240. if (failInvoice) {
  241. code = 500
  242. msg = '开票测试失败'
  243. failInvoice = false
  244. } else {
  245. data = { ...body, id: 'invoice-1', createBy: 'user-1' }
  246. invoices.push(data)
  247. }
  248. } else if (path === '/web/crm/invoice-info/update') {
  249. Object.assign(
  250. invoices.find((i) => i.id === body.id),
  251. body
  252. )
  253. data = body
  254. } else if (path === '/web/crm/invoice-info/set-default')
  255. invoices.forEach((i) => (i.isDefault = i.id === body.id ? '1' : '0'))
  256. else if (path === '/web/crm/invoice-info/delete')
  257. invoices = invoices.filter((i) => i.id !== body.id)
  258. else if (path === '/web/admin/attachment/upload') {
  259. signalUploadStarted?.()
  260. signalUploadStarted = null
  261. if (uploadGate) await uploadGate
  262. const queued = uploadQueue.shift()
  263. if (queued?.fail) {
  264. code = 500
  265. msg = queued.fail
  266. } else {
  267. data = queued?.data || {
  268. id: 'file-1',
  269. fileName: '证件.pdf',
  270. fileUrl: '/fixtures/customer.pdf',
  271. fileType: 'application/pdf'
  272. }
  273. }
  274. } else if (path === '/web/admin/attachment/preview-url') data = { previewUrl: body.fileUrl }
  275. else if (path === '/web/crm/opportunity/list')
  276. data = {
  277. list: [opportunity],
  278. total: 1
  279. }
  280. else if (path === '/web/crm/opportunity/detail') data = opportunity
  281. else if (path === '/web/crm/opportunity/update' || path === '/web/crm/opportunity/stage')
  282. Object.assign(opportunity, body)
  283. else if (path === '/web/crm/opportunity/follow/list')
  284. data = { list: opportunityFollows, total: opportunityFollows.length }
  285. else if (path === '/web/crm/opportunity/follow/add')
  286. opportunityFollows.push({ ...body, id: 'opp-follow-1', createBy: 'user-1' })
  287. else if (path === '/web/crm/opportunity/follow/update') Object.assign(opportunityFollows[0], body)
  288. else if (path === '/web/crm/opportunity/follow/delete') opportunityFollows = []
  289. else if (path.includes('/opportunity/')) data = { list: [] }
  290. else if (path === '/web/crm/contract/list')
  291. data = { rows: [{ id: 'contract-1', contractName: '测试合同' }], total: 1 }
  292. else if (path === '/web/monitor/operlog/list')
  293. data = {
  294. rows: [
  295. {
  296. id: 'log-1',
  297. title: 'CRM客户管理',
  298. operName: '测试用户',
  299. status: '0',
  300. operParam: '<img src=x onerror=alert(1)>',
  301. operTime: '2026-10-17 10:00:00'
  302. }
  303. ],
  304. total: 1
  305. }
  306. else throw new Error(`未处理的客户 E2E API:${path}`)
  307. await route.fulfill({
  308. contentType: 'application/json',
  309. body: JSON.stringify({ code, msg, data })
  310. })
  311. })
  312. const label = (name) => page.getByLabel(name, { exact: true }).filter({ visible: true })
  313. const click = (name) =>
  314. page
  315. .getByRole('button', { name, exact: true })
  316. .or(page.getByRole('tab', { name, exact: true }))
  317. .filter({ visible: true })
  318. .click()
  319. const waitHeading = (name) => page.getByRole('heading', { name, exact: true }).waitFor()
  320. const closeSheet = () =>
  321. page.getByRole('button', { name: '关闭', exact: true }).filter({ visible: true }).click()
  322. const hideDevTools = async () => {
  323. await page.addStyleTag({
  324. content:
  325. '#vue-devtools-anchor,.vue-devtools__anchor,.vue-devtools-frame{display:none!important}'
  326. })
  327. assert.equal(await page.locator('#vue-devtools-anchor').filter({ visible: true }).count(), 0)
  328. }
  329. const assertNoOverflow = async (label) => {
  330. const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)
  331. assert.equal(overflow, false, `${label}不应横向溢出`)
  332. }
  333. const assertMinTouchTargets = async (locator, label, minHeight = 44) => {
  334. const sizes = await locator.evaluateAll((elements) =>
  335. elements
  336. .filter((element) => element.getClientRects().length > 0)
  337. .map((element) => ({
  338. width: element.getBoundingClientRect().width,
  339. height: element.getBoundingClientRect().height
  340. }))
  341. )
  342. assert.ok(sizes.length > 0, `${label}应存在可见触控目标`)
  343. assert.ok(
  344. sizes.every(({ width, height }) => width >= 44 && height >= minHeight),
  345. `${label}触控目标应至少为44×${minHeight}px:${JSON.stringify(sizes)}`
  346. )
  347. }
  348. const waitForMessagesToClear = () =>
  349. page.waitForFunction(() => !document.querySelector('.el-message'), undefined, { timeout: 6000 })
  350. try {
  351. await page.goto(`${origin}/#/m/crm/customer?mpTicket=customer-forbidden-list`)
  352. await page.locator('.mp-customer-error').filter({ hasText: '无权限访问客户列表' }).waitFor()
  353. assert.equal(await page.locator('.mp-crm-expense-card').count(), 0)
  354. assert.equal(await page.locator('.mp-wv-status-tabs').count(), 0)
  355. assert.equal(await page.locator('.mp-wv-filter-bar--three').count(), 0)
  356. assert.equal(await page.locator('.mp-customer-summary').count(), 0)
  357. assert.equal(await page.locator('.mp-wv-speed-dial').count(), 0)
  358. assert.equal(await page.locator('.mp-customer-batch-bar').count(), 0)
  359. assert.equal(await page.locator('.mp-customer-sheet').count(), 0)
  360. await page.goto(
  361. `${origin}/#/m/crm/customer/${customers[0].id}?mpTicket=customer-forbidden-detail`
  362. )
  363. await page.locator('.mp-customer-error').filter({ hasText: '无权限访问客户详情' }).waitFor()
  364. await page.goto(`${origin}/#/m/crm/customer?mpTicket=customer-fixture`)
  365. await hideDevTools()
  366. await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).waitFor()
  367. const skeletonOrder = await page
  368. .locator('.mp-wv-header, .mp-wv-status-tabs, .mp-wv-filter-bar--three, .mp-crm-expense-card')
  369. .evaluateAll((nodes) => nodes.map((node) => node.className))
  370. assert.match(String(skeletonOrder[0]), /mp-wv-header/)
  371. assert.match(String(skeletonOrder[1]), /mp-wv-status-tabs/)
  372. assert.match(String(skeletonOrder[2]), /mp-wv-filter-bar--three/)
  373. assert.match(String(skeletonOrder[3]), /mp-crm-expense-card/)
  374. assert.equal(await page.locator('.mp-customer-actions').filter({ visible: true }).count(), 0)
  375. assert.equal(await page.locator('.mp-wv-speed-dial').count(), 1)
  376. assert.equal(await page.getByText('导出', { exact: true }).count(), 0)
  377. const cardActionSizes = await page
  378. .locator('.mp-crm-expense-card .op-actions button')
  379. .evaluateAll((buttons) =>
  380. buttons.map((button) => ({
  381. width: button.getBoundingClientRect().width,
  382. height: button.getBoundingClientRect().height
  383. }))
  384. )
  385. assert.ok(cardActionSizes.every(({ width, height }) => width >= 44 && height >= 28 && height <= 32))
  386. await assertMinTouchTargets(page.locator('.mp-wv-speed-main'), 'Speed Dial主按钮')
  387. await click('快捷操作')
  388. await assertMinTouchTargets(page.locator('.mp-wv-speed-item'), 'Speed Dial菜单', 30)
  389. await click('批量选择')
  390. await assertMinTouchTargets(page.locator('.mp-customer-batch-bar button'), '批量操作栏', 30)
  391. await page.locator('.expense-card__select').first().click()
  392. assert.equal(await page.locator('.mp-crm-expense-card .ec-bottom').count(), 0)
  393. failList = true
  394. await click('重点')
  395. await page.getByText('列表测试失败', { exact: true }).first().waitFor()
  396. assert.equal(await page.locator('.mp-crm-expense-card').count(), 0)
  397. assert.match(await page.locator('.mp-customer-summary').innerText(), /共 0 位客户 · 已加载 0 位/)
  398. assert.match(await page.locator('.mp-customer-batch-bar').innerText(), /已选 0 项/)
  399. await click('重试')
  400. await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).waitFor()
  401. await click('完成')
  402. await click('全部')
  403. failNextPage2 = true
  404. await click('加载更多(1/2)')
  405. await page.getByText('分页测试失败', { exact: true }).waitFor()
  406. await click('重试')
  407. await page.getByRole('button', { name: '查看客户:分页客户', exact: true }).waitFor()
  408. const page2Calls = calls.filter(
  409. (call) => call.path.endsWith('/customer/list') && Number(call.body.page) === 2
  410. )
  411. assert.deepEqual(
  412. page2Calls.slice(-2).map((call) => Number(call.body.page)),
  413. [2, 2],
  414. '分页失败后重试不得跳到第 3 页'
  415. )
  416. await page.getByLabel('搜索客户').fill('测试')
  417. await Promise.all([
  418. page.waitForResponse((response) => response.url().endsWith('/customer/list')),
  419. page.getByLabel('搜索客户').press('Enter')
  420. ])
  421. await Promise.all([
  422. page.waitForResponse((response) => response.url().endsWith('/customer/list')),
  423. page.getByLabel('搜索客户').fill('')
  424. ])
  425. assert.equal(
  426. calls.findLast((call) => call.path.endsWith('/customer/list')).body.keyword,
  427. undefined
  428. )
  429. delayNextPage2 = true
  430. await click('加载更多(1/2)')
  431. await click('客户类型')
  432. await click('企业')
  433. await page.waitForTimeout(650)
  434. assert.equal(
  435. await page.getByRole('button', { name: '查看客户:分页客户', exact: true }).count(),
  436. 0,
  437. '旧筛选的第 2 页结果不得混入新筛选'
  438. )
  439. await click('企业')
  440. await click('全部类型')
  441. for (const [name, endpoint] of [
  442. ['待跟进', 'toFollow'],
  443. ['重点', 'key'],
  444. ['全部', 'list']
  445. ]) {
  446. await Promise.all([
  447. page.waitForResponse((r) => r.url().endsWith('/customer/' + endpoint)),
  448. click(name)
  449. ])
  450. }
  451. await Promise.all([
  452. page.waitForResponse((r) => r.url().includes('/customer/mine')),
  453. click('我的')
  454. ])
  455. assert.equal(calls.findLast((c) => c.path.endsWith('/customer/mine')).body.viewMode, 'mine')
  456. await page.getByLabel('搜索客户').fill('顶部词')
  457. await Promise.all([
  458. page.waitForResponse((r) => r.url().includes('/customer/mine')),
  459. page.getByLabel('搜索客户').press('Enter')
  460. ])
  461. await page.getByRole('button', { name: /^更多/ }).click()
  462. const filterSheet = page.locator('.mp-sheet').filter({ hasText: '更多筛选' })
  463. await assertMinTouchTargets(
  464. filterSheet.locator('.mp-sheet-close, .mp-sheet-foot button'),
  465. '更多筛选底部与关闭按钮',
  466. 30
  467. )
  468. assert.equal(await filterSheet.getByLabel('关键词').inputValue(), '顶部词')
  469. assert.equal(await filterSheet.getByLabel('业务视图').inputValue(), 'mine')
  470. await filterSheet.getByLabel('关键词').fill('面板词')
  471. await filterSheet.getByLabel('业务视图').selectOption('key')
  472. await filterSheet.getByLabel('排序字段').selectOption('createTime')
  473. const mobileFilter = filterSheet
  474. .locator('details')
  475. .filter({ has: page.locator('summary:text-is("手机号")') })
  476. await mobileFilter.locator('summary').click()
  477. await mobileFilter.locator('input').fill('138')
  478. const followCountFilter = filterSheet
  479. .locator('details')
  480. .filter({ has: page.locator('summary:text-is("跟进次数")') })
  481. await followCountFilter.locator('summary').click()
  482. await followCountFilter.getByLabel('最小值').fill('1')
  483. const createTimeFilter = filterSheet
  484. .locator('details')
  485. .filter({ has: page.locator('summary:text-is("创建时间")') })
  486. await createTimeFilter.locator('summary').click()
  487. await createTimeFilter.getByLabel('起始时间').fill('2026-10-01T08:00')
  488. const customerNoFilter = filterSheet
  489. .locator('details')
  490. .filter({ has: page.locator('summary:text-is("客户编号")') })
  491. await customerNoFilter.locator('summary').click()
  492. await Promise.all([
  493. page.waitForResponse((r) => r.url().endsWith('/customer/column-facet-values')),
  494. customerNoFilter.locator('input[type="search"]').fill('远端')
  495. ])
  496. assert.equal(
  497. calls.findLast((c) => c.path.endsWith('/customer/column-facet-values')).body.keyword,
  498. '远端'
  499. )
  500. await customerNoFilter.getByText('第201号外值', { exact: true }).waitFor()
  501. await page.locator('summary').filter({ hasText: '客户类型' }).click()
  502. await page.getByLabel('企业', { exact: true }).check()
  503. await page.getByLabel('个人', { exact: true }).check()
  504. await page.locator('summary').filter({ hasText: '客户级别' }).click()
  505. await page.getByLabel('普通', { exact: true }).check()
  506. await page.getByLabel('重点', { exact: true }).check()
  507. await page.getByLabel('客户简称', { exact: true }).uncheck()
  508. await Promise.all([
  509. page.waitForResponse((r) => r.url().includes('/customer/key')),
  510. click('应用筛选')
  511. ])
  512. const appliedFilterCall = calls.findLast((c) => c.path.endsWith('/customer/key'))
  513. assert.equal(appliedFilterCall.body.keyword, '面板词')
  514. assert.equal(appliedFilterCall.body.customerTypeIn, 'company,person')
  515. assert.equal(appliedFilterCall.body.levelIn, 'normal,key')
  516. assert.equal(appliedFilterCall.body.mobile, '138')
  517. assert.equal(appliedFilterCall.body.followCountMin, '1')
  518. assert.equal(appliedFilterCall.body.beginTime, '2026-10-01 08:00:00')
  519. assert.equal(appliedFilterCall.body.sortField, 'createTime')
  520. assert.equal(await page.getByLabel('搜索客户').inputValue(), '面板词')
  521. assert.equal(
  522. await page.getByRole('button', { name: '重点', exact: true }).getAttribute('aria-pressed'),
  523. 'true'
  524. )
  525. assert.deepEqual(
  526. await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').allTextContents(),
  527. ['已选2项', '已选2项']
  528. )
  529. await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig').first().click()
  530. await click('企业')
  531. assert.equal(
  532. calls.findLast((c) => c.path.endsWith('/customer/key')).body.customerTypeIn,
  533. 'company'
  534. )
  535. assert.equal(
  536. await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').first().innerText(),
  537. '企业'
  538. )
  539. assert.equal(
  540. await page
  541. .locator('.mp-crm-expense-card')
  542. .first()
  543. .getByText('客户简称', { exact: true })
  544. .count(),
  545. 0
  546. )
  547. await page.getByRole('button', { name: /^更多/ }).click()
  548. await Promise.all([
  549. page.waitForResponse((r) => r.url().endsWith('/customer/list')),
  550. filterSheet.getByRole('button', { name: '重置', exact: true }).click()
  551. ])
  552. assert.equal(await page.getByLabel('搜索客户').inputValue(), '')
  553. assert.equal(
  554. await page.getByRole('button', { name: '全部', exact: true }).getAttribute('aria-pressed'),
  555. 'true'
  556. )
  557. assert.deepEqual(
  558. await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig__val').allTextContents(),
  559. ['客户类型', '客户级别']
  560. )
  561. assert.equal(
  562. await page
  563. .locator('.mp-crm-expense-card')
  564. .first()
  565. .getByText('客户简称', { exact: true })
  566. .count(),
  567. 1
  568. )
  569. await page.waitForTimeout(3200)
  570. await page.setViewportSize({ width: 414, height: 812 })
  571. await hideDevTools()
  572. await assertNoOverflow('414px 有数据列表')
  573. await page.screenshot({ path: `${artifacts}/list-414.png`, fullPage: true })
  574. await page.setViewportSize({ width: 375, height: 812 })
  575. await assertNoOverflow('375px 有数据列表')
  576. await page.screenshot({ path: `${artifacts}/list-375.png`, fullPage: true })
  577. await page.getByRole('button', { name: `查看客户:${customers[0].customerName}` }).click()
  578. await waitHeading('客户详情')
  579. await page.goto(`${origin}/#/m/crm/customer/${customers[0].id}`)
  580. await waitHeading('客户详情')
  581. await page.reload()
  582. await waitHeading('客户详情')
  583. customers[0].createBy = 'another-user'
  584. await page.reload()
  585. await waitHeading('客户详情')
  586. assert.equal(await page.getByRole('button', { name: '删除', exact: true }).isDisabled(), true)
  587. customers[0].createBy = 'user-1'
  588. await page.reload()
  589. await waitHeading('客户详情')
  590. assert.equal(await page.getByRole('button', { name: '删除', exact: true }).isEnabled(), true)
  591. const detailActionSizes = await page.locator('.mp-wv-fab-bar button').evaluateAll((buttons) =>
  592. buttons.map((button) => ({
  593. width: button.getBoundingClientRect().width,
  594. height: button.getBoundingClientRect().height
  595. }))
  596. )
  597. assert.ok(detailActionSizes.every(({ width, height }) => width >= 44 && height >= 28 && height <= 32))
  598. assert.deepEqual(await page.locator('.mp-customer-detail-tabs button').allTextContents(), [
  599. '资料',
  600. '跟进记录',
  601. '联系人',
  602. '商机',
  603. '合同',
  604. '开票信息',
  605. '操作日志'
  606. ])
  607. const detailTag = page.locator('.mp-customer-webview-tag').first()
  608. await detailTag.waitFor()
  609. assert.notEqual(
  610. await detailTag.evaluate((tag) => getComputedStyle(tag).color),
  611. 'rgb(255, 255, 255)'
  612. )
  613. await page.setViewportSize({ width: 414, height: 812 })
  614. await hideDevTools()
  615. await assertNoOverflow('414px 客户详情')
  616. await page.screenshot({ path: `${artifacts}/detail-414.png`, fullPage: true })
  617. await page.setViewportSize({ width: 375, height: 812 })
  618. await page.goto(`${origin}/#/m/crm/customer/${secondCustomer.id}?tab=invoice`)
  619. await page.getByText('暂无开票信息', { exact: true }).waitFor()
  620. assert.equal(
  621. calls.findLast((call) => call.path.endsWith('/customer/detail')).body.id,
  622. secondCustomer.id
  623. )
  624. assert.ok(
  625. await page
  626. .locator('.mp-customer-detail-tabs button')
  627. .filter({ hasText: '开票信息' })
  628. .evaluate((button) => button.classList.contains('active'))
  629. )
  630. delayedDetailId = customers[0].id
  631. const slowARequest = page.waitForRequest((request) => {
  632. if (!request.url().endsWith('/web/crm/customer/detail')) return false
  633. try {
  634. return request.postDataJSON()?.id === customers[0].id
  635. } catch {
  636. return false
  637. }
  638. })
  639. await page.evaluate((id) => {
  640. location.hash = `#/m/crm/customer/${id}?tab=follow`
  641. }, customers[0].id)
  642. await slowARequest
  643. await page.evaluate((id) => {
  644. location.hash = `#/m/crm/customer/${id}?tab=contact`
  645. }, secondCustomer.id)
  646. await page.getByRole('heading', { name: '张经理', exact: true }).waitFor()
  647. await page.waitForTimeout(650)
  648. assert.ok(
  649. await page
  650. .locator('.mp-customer-detail-tabs button')
  651. .filter({ hasText: '联系人' })
  652. .evaluate((button) => button.classList.contains('active'))
  653. )
  654. await click('资料')
  655. await page.getByRole('heading', { name: secondCustomer.customerName, exact: true }).waitFor()
  656. assert.equal(await page.getByRole('heading', { name: customers[0].customerName }).count(), 0)
  657. delayedDetailId = ''
  658. await page.evaluate((id) => {
  659. location.hash = `#/m/crm/customer/${id}?tab=follow`
  660. }, customers[0].id)
  661. await page.getByRole('button', { name: '+ 添加跟进', exact: true }).waitFor()
  662. assert.ok(
  663. await page
  664. .locator('.mp-customer-detail-tabs button')
  665. .filter({ hasText: '跟进记录' })
  666. .evaluate((button) => button.classList.contains('active'))
  667. )
  668. await click('跟进记录')
  669. await click('+ 添加跟进')
  670. await label('关联联系人').selectOption('contact-1')
  671. await label('跟进内容 *').fill('移动端跟进测试')
  672. await label('下次联系时间').fill('2026-10-18T10:00')
  673. await page
  674. .locator('.mp-sheet')
  675. .filter({ hasText: '添加跟进记录' })
  676. .locator('input[type=file]')
  677. .setInputFiles({
  678. name: '跟进附件.pdf',
  679. mimeType: 'application/pdf',
  680. buffer: Buffer.from('%PDF-1.4\nfollow-fixture')
  681. })
  682. await page.getByText('customer.pdf', { exact: true }).filter({ visible: true }).waitFor()
  683. await click('保存跟进')
  684. await page.getByText('移动端跟进测试', { exact: true }).waitFor()
  685. assert.equal(follows[0].customerId, '90071992547409931')
  686. assert.equal(follows[0].nextTime, '2026-10-18 10:00:00')
  687. assert.equal(follows[0].attachment, '/fixtures/customer.pdf')
  688. const followAttachment = page
  689. .locator('.mp-wv-card')
  690. .filter({ hasText: '移动端跟进测试' })
  691. .locator('.att-item')
  692. .first()
  693. const followAttachmentBox = await followAttachment.boundingBox()
  694. assert.equal(followAttachmentBox?.width, 72)
  695. assert.equal(followAttachmentBox?.height, 72)
  696. await followAttachment.locator('.att-preview').click()
  697. await page.getByRole('dialog').getByText('customer.pdf', { exact: true }).waitFor()
  698. await page.keyboard.press('Escape')
  699. await click('联系人')
  700. await page.getByRole('heading', { name: '张经理', exact: true }).waitFor()
  701. await click('+ 新增联系人')
  702. await label('联系人姓名 *').fill('李经理')
  703. await label('手机号 *').fill('13900139000')
  704. await click('保存联系人')
  705. await page.getByRole('heading', { name: '李经理', exact: true }).waitFor()
  706. assert.equal(contacts[1].customerId, '90071992547409931')
  707. await click('开票信息')
  708. await click('+ 新增开票信息')
  709. await label('单位名称(章) *').fill('测试开票单位')
  710. await label('税号').fill('123456789')
  711. await click('保存开票信息')
  712. await page.getByText('测试开票单位', { exact: true }).waitFor()
  713. await click('设为默认')
  714. await page.getByText('默认', { exact: true }).filter({ visible: true }).waitFor()
  715. assert.equal(invoices[0].isDefault, '1')
  716. await click('操作日志')
  717. await click('CRM客户管理')
  718. await page.getByText('<img src=x onerror=alert(1)>', { exact: true }).waitFor()
  719. assert.equal(await page.locator('img[src="x"]').count(), 0)
  720. await closeSheet()
  721. assert.equal(await page.locator('.mp-sheet-mask').filter({ visible: true }).count(), 0)
  722. await click('商机')
  723. await click('测试商机')
  724. await page.getByText('123', { exact: true }).waitFor()
  725. await click('修改商机')
  726. await label('商机名称 *').fill('移动修改商机')
  727. await click('保存商机操作')
  728. await page.getByText('移动修改商机', { exact: true }).filter({ visible: true }).first().waitFor()
  729. assert.equal(opportunity.customerId, '90071992547409931')
  730. await click('推进阶段')
  731. await label('商机阶段 *').selectOption('quote')
  732. await label('变更原因 *').fill('已报价')
  733. await click('保存商机操作')
  734. await page.getByRole('button', { name: '推进阶段', exact: true }).waitFor()
  735. assert.equal(opportunity.stagePercent, 60)
  736. await click('添加商机跟进')
  737. await label('商机跟进内容 *').fill('商机跟进测试')
  738. await click('保存商机操作')
  739. await page.getByText('商机跟进测试', { exact: true }).waitFor()
  740. await click('编辑商机跟进')
  741. await label('商机跟进内容 *').fill('商机跟进已编辑')
  742. await click('保存商机操作')
  743. await page.getByText('商机跟进已编辑', { exact: true }).waitFor()
  744. await click('删除商机跟进')
  745. await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
  746. await page.getByText('暂无跟进记录', { exact: true }).waitFor()
  747. await closeSheet()
  748. await click('合同')
  749. await page.getByText('测试合同', { exact: true }).waitFor()
  750. await click('资料')
  751. await page.screenshot({ path: `${artifacts}/detail-375.png`, fullPage: true })
  752. await click('编辑')
  753. await waitHeading('编辑客户')
  754. await label('客户简称 *').fill('移动编辑')
  755. const certificateInput = page.locator('input[type=file]').first()
  756. let releaseUploadGate
  757. uploadGate = new Promise((resolve) => {
  758. releaseUploadGate = resolve
  759. })
  760. const uploadStarted = new Promise((resolve) => {
  761. signalUploadStarted = resolve
  762. })
  763. const uploadCallsBeforeConcurrent = calls.filter(
  764. (call) => call.path === '/web/admin/attachment/upload'
  765. ).length
  766. await certificateInput.setInputFiles({
  767. name: '并发首个.pdf',
  768. mimeType: 'application/pdf',
  769. buffer: Buffer.from('%PDF-1.4\nconcurrent-first')
  770. })
  771. await uploadStarted
  772. await certificateInput.evaluate((input) => {
  773. const transfer = new DataTransfer()
  774. transfer.items.add(new File(['second'], '并发第二个.pdf', { type: 'application/pdf' }))
  775. Object.defineProperty(input, 'files', { value: transfer.files, configurable: true })
  776. input.dispatchEvent(new Event('change', { bubbles: true }))
  777. })
  778. await page.waitForTimeout(100)
  779. assert.equal(
  780. calls.filter((call) => call.path === '/web/admin/attachment/upload').length,
  781. uploadCallsBeforeConcurrent + 1,
  782. '上传期间再次选择不得启动第二个上传请求'
  783. )
  784. releaseUploadGate()
  785. uploadGate = null
  786. await page.getByText('customer.pdf', { exact: true }).waitFor()
  787. await page.waitForFunction(() => {
  788. const input = document.querySelector('input[type=file]')
  789. return !!input && !input.disabled
  790. })
  791. uploadQueue = [
  792. {
  793. data: {
  794. id: 'partial-ok',
  795. fileName: '部分成功.pdf',
  796. fileUrl: '/fixtures/partial-ok.pdf',
  797. fileType: 'application/pdf'
  798. }
  799. }
  800. ]
  801. const uploadCallsBeforePartialFailure = calls.filter(
  802. (call) => call.path === '/web/admin/attachment/upload'
  803. ).length
  804. await certificateInput.setInputFiles({
  805. name: '部分成功.pdf',
  806. mimeType: 'application/pdf',
  807. buffer: Buffer.from('%PDF-1.4\npartial-ok')
  808. })
  809. await page.getByText('partial-ok.pdf', { exact: true }).waitFor()
  810. await page.waitForFunction(() => {
  811. const input = document.querySelector('input[type=file]')
  812. return !!input && !input.disabled
  813. })
  814. uploadQueue = [{ fail: '后续上传失败' }]
  815. await certificateInput.setInputFiles({
  816. name: '后续失败.pdf',
  817. mimeType: 'application/pdf',
  818. buffer: Buffer.from('%PDF-1.4\npartial-fail')
  819. })
  820. for (
  821. let attempt = 0;
  822. attempt < 100 &&
  823. calls.filter((call) => call.path === '/web/admin/attachment/upload').length <
  824. uploadCallsBeforePartialFailure + 2;
  825. attempt += 1
  826. )
  827. await page.waitForTimeout(20)
  828. await page.getByText('后续上传失败', { exact: true }).last().waitFor()
  829. assert.equal(uploadQueue.length, 0, '后续失败请求应被处理')
  830. assert.equal(
  831. calls.filter((call) => call.path === '/web/admin/attachment/upload').length,
  832. uploadCallsBeforePartialFailure + 2
  833. )
  834. assert.ok(await page.getByText('customer.pdf', { exact: true }).isVisible())
  835. await assertMinTouchTargets(
  836. page.locator(
  837. '.mp-customer-attachments .att-preview, .mp-customer-attachments .att-remove, .mp-customer-attachments .att-add'
  838. ),
  839. '附件预览、删除与添加'
  840. )
  841. await waitForMessagesToClear()
  842. await page.setViewportSize({ width: 414, height: 812 })
  843. await hideDevTools()
  844. await assertNoOverflow('414px 附件表单')
  845. await page.screenshot({ path: `${artifacts}/form-attachments-414.png`, fullPage: true })
  846. await page.setViewportSize({ width: 375, height: 812 })
  847. await click('保存客户')
  848. await waitHeading('客户详情')
  849. assert.equal(customers[0].shortName, '移动编辑')
  850. assert.match(customers[0].businessLicenseImages, /\/fixtures\/customer\.pdf/)
  851. assert.match(customers[0].businessLicenseImages, /\/fixtures\/partial-ok\.pdf/)
  852. await page.getByRole('button', { name: '返回客户列表' }).click()
  853. await click('快捷操作')
  854. await click('新增客户')
  855. await waitHeading('新增客户')
  856. for (const [name, value] of [
  857. ['客户名称 *', '新客户'],
  858. ['客户简称 *', '新'],
  859. ['省份 *', '湖北'],
  860. ['城市 *', '宜昌'],
  861. ['区县 *', '西陵'],
  862. ['所在园区 *', '园区A']
  863. ])
  864. await label(name).fill(value)
  865. await label('客户类型 *').selectOption('company')
  866. await label('客户来源 *').selectOption('phone')
  867. await label('单位名称(章)').fill('新增开票单位')
  868. failInvoice = true
  869. await click('保存客户')
  870. await page.getByText('客户已创建。请补存开票资料,无需再次新增客户。').waitFor()
  871. await click('补存开票资料')
  872. await waitHeading('客户详情')
  873. assert.equal(calls.filter((c) => c.path === '/web/crm/customer/add').length, 1)
  874. await page.getByRole('button', { name: '返回客户列表' }).click()
  875. await page.getByRole('button', { name: '查看客户:新客户', exact: true }).waitFor()
  876. await click('快捷操作')
  877. await click('批量选择')
  878. await page
  879. .locator('.mp-crm-expense-card')
  880. .filter({ hasText: '新客户' })
  881. .locator('.expense-card__select')
  882. .click()
  883. await click('放入公海')
  884. await label('放入原因 *').fill('测试批量放入')
  885. await click('确认放入')
  886. await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
  887. await page.getByRole('button', { name: '查看客户:新客户', exact: true }).waitFor({
  888. state: 'hidden'
  889. })
  890. assert.deepEqual(calls.findLast((c) => c.path.endsWith('/customer/intoPool')).body.ids, [
  891. 'created-customer'
  892. ])
  893. await click('快捷操作')
  894. await click('批量选择')
  895. await page.locator('.expense-card__select').first().click()
  896. await click('删除')
  897. await page.getByRole('button', { name: /^(确定|确认)$/ }).click()
  898. await page.getByText('暂无客户,可调整筛选后重试').waitFor()
  899. assert.deepEqual(calls.findLast((c) => c.path.endsWith('/customer/delete')).body.ids, [
  900. '90071992547409931'
  901. ])
  902. failList = true
  903. await page.getByLabel('搜索客户').fill('失败')
  904. await page.getByLabel('搜索客户').press('Enter')
  905. await page.getByText('列表测试失败', { exact: true }).first().waitFor()
  906. await click('重试')
  907. await page.getByText('暂无客户,可调整筛选后重试').waitFor()
  908. await waitForMessagesToClear()
  909. for (const width of [375, 414]) {
  910. await page.setViewportSize({ width, height: 812 })
  911. await assertNoOverflow(`${width}px 空列表`)
  912. assert.equal(await page.locator('.mp-sheet-mask').filter({ visible: true }).count(), 0)
  913. assert.equal(await page.locator('.mp-wv-speed-mask.open').count(), 0)
  914. if (width === 414)
  915. await page.screenshot({ path: `${artifacts}/list-empty-414.png`, fullPage: true })
  916. }
  917. assert.deepEqual(pageErrors, [])
  918. console.log(
  919. JSON.stringify({
  920. result: 'PASS',
  921. assertions:
  922. '四视图、快捷/高级筛选与显示字段、搜索清空、分页失败回滚与乱序隔离、卡片动作、直达详情刷新、创建人权限、客户跟进/联系人/商机/合同/开票/日志、72px附件上传预览、客户编辑与新增失败重试、批量公海/删除、44px触达、375/414无溢出遮罩',
  923. apiCalls: calls.length,
  924. screenshots: artifacts
  925. })
  926. )
  927. } catch (error) {
  928. await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true })
  929. console.error(await page.locator('body').innerText())
  930. console.error('Page errors:', pageErrors)
  931. console.error(
  932. 'API calls:',
  933. calls.map((c) => c.path)
  934. )
  935. throw error
  936. } finally {
  937. await context.close()
  938. await browser.close()
  939. }