test-mp-crm-product.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import assert from 'node:assert/strict'
  2. import { mkdir } from 'node:fs/promises'
  3. import { chromium } from 'playwright'
  4. // 本地 H5 交互回归:所有 API 均由 fixture 拦截,不写真实业务库。
  5. const origin = process.env.PRODUCT_TEST_ORIGIN || 'http://localhost:3006'
  6. const artifacts = '/tmp/shanxia-product-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. const calls = []
  17. const unknownPaths = []
  18. page.on('pageerror', (error) => pageErrors.push(error.message))
  19. const image = `${origin}/fixture-product.svg`
  20. const ownProduct = {
  21. id: 'product-1',
  22. productNo: 'CP-001',
  23. productName: '工业甲醇',
  24. namePinyinAbbr: 'GYJC',
  25. categoryId: 'category-2',
  26. categoryName: '醇类',
  27. model: '优级品',
  28. barcode: '690000000001',
  29. taxRate: 13,
  30. stock: 20,
  31. lowStock: 5,
  32. status: 'on',
  33. isToxic: 1,
  34. isExplosive: 0,
  35. description: '产品说明',
  36. remark: 'fixture 产品',
  37. images: image,
  38. brands: [{ id: 'brand-1', brandName: '山峡牌', status: 'on' }],
  39. aliases: [{ id: 'alias-1', aliasName: '木醇' }],
  40. createBy: 'user-1',
  41. createTime: '2026-10-17 09:00:00'
  42. }
  43. const foreignProduct = {
  44. ...ownProduct,
  45. id: 'product-foreign',
  46. productNo: 'CP-002',
  47. productName: '外部创建产品',
  48. status: 'off',
  49. createBy: 'user-2'
  50. }
  51. let failNextPage2 = false
  52. let forbidNextList = false
  53. let failNextCategory = true
  54. await context.addInitScript(() => {
  55. window.wx = {
  56. miniProgram: {
  57. navigateBack() {},
  58. reLaunch() {},
  59. getEnv(callback) {
  60. callback({ miniprogram: true })
  61. }
  62. }
  63. }
  64. })
  65. await context.route('**/fixture-product.svg', (route) =>
  66. route.fulfill({
  67. contentType: 'image/svg+xml',
  68. body: '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="160" height="160" fill="#dff7ed"/><text x="28" y="86">产品图片</text></svg>'
  69. })
  70. )
  71. await context.route('**/api/**', async (route) => {
  72. const request = route.request()
  73. const url = new URL(request.url())
  74. if (!url.pathname.startsWith('/api/')) return route.continue()
  75. const path = url.pathname.replace(/^\/api/, '')
  76. let body = {}
  77. try {
  78. body = request.postDataJSON() || {}
  79. } catch {
  80. // 上传类请求不参与 JSON body 断言。
  81. }
  82. calls.push({ path, body })
  83. let code = 200
  84. let msg = 'ok'
  85. let data = {}
  86. if (path === '/web/auth/mp-webview-exchange') {
  87. data = {
  88. token: 'fixture-token',
  89. refreshToken: 'fixture-refresh',
  90. userInfo: { id: 'user-1', userId: 'user-1', userName: '测试用户', roles: ['admin'] }
  91. }
  92. } else if (path === '/web/admin/dict-data/all') {
  93. data = {
  94. crm_product_status: [
  95. { value: 'on', label: '上架', tagType: 'success' },
  96. { value: 'off', label: '下架', tagType: 'info' }
  97. ]
  98. }
  99. } else if (path === '/web/crm/product-categories/tree') {
  100. if (failNextCategory) {
  101. failNextCategory = false
  102. code = 500
  103. msg = '分类树测试失败'
  104. } else {
  105. data = [
  106. {
  107. id: 'category-1',
  108. categoryName: '化工原料',
  109. children: [{ id: 'category-2', categoryName: '醇类', children: [] }]
  110. }
  111. ]
  112. }
  113. } else if (path === '/web/crm/products/column-facet-values') {
  114. data = {
  115. list: [
  116. { value: '优级品', label: '优级品', count: 2 },
  117. { value: '桶装(首屏外)', label: '桶装(首屏外)', count: 1 }
  118. ]
  119. }
  120. } else if (path === '/web/crm/products/list') {
  121. if (forbidNextList) {
  122. forbidNextList = false
  123. code = 403
  124. msg = '无权限'
  125. } else if (Number(body.page) === 2 && failNextPage2) {
  126. failNextPage2 = false
  127. code = 500
  128. msg = '分页测试失败'
  129. } else {
  130. data = {
  131. list:
  132. Number(body.page) === 2
  133. ? [{ ...ownProduct, id: 'product-3', productNo: 'CP-003' }]
  134. : [ownProduct, foreignProduct],
  135. total: 22
  136. }
  137. }
  138. } else if (path === '/web/crm/products/detail') {
  139. if (body.id === 'product-slow') {
  140. await new Promise((resolve) => setTimeout(resolve, 450))
  141. data = {
  142. ...ownProduct,
  143. id: 'product-slow',
  144. productNo: 'CP-SLOW',
  145. productName: '迟到的旧产品响应'
  146. }
  147. } else {
  148. data = body.id === foreignProduct.id ? foreignProduct : ownProduct
  149. }
  150. } else if (path === '/web/crm/products/update-status') {
  151. ownProduct.status = body.status
  152. data = true
  153. } else if (path === '/web/crm/products/delete') {
  154. data = true
  155. } else if (path === '/web/admin/attachment/upload') {
  156. data = {
  157. id: 'upload-1',
  158. fileName: 'new-product.png',
  159. fileUrl: image,
  160. fileSize: 16,
  161. fileType: 'image/png'
  162. }
  163. } else {
  164. unknownPaths.push(path)
  165. code = 500
  166. msg = `未模拟 API: ${path}`
  167. }
  168. await route.fulfill({
  169. contentType: 'application/json',
  170. body: JSON.stringify({ code, msg, data })
  171. })
  172. })
  173. const lastCall = (path) => calls.findLast((item) => item.path === path)
  174. async function assertNoOverflow(width) {
  175. await page.setViewportSize({ width, height: 812 })
  176. assert.equal(
  177. await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
  178. false,
  179. `页面在 ${width}px 不应横向溢出`
  180. )
  181. }
  182. async function hideDevtools() {
  183. await page.addStyleTag({
  184. content:
  185. 'vite-plugin-vue-devtools,#vue-devtools-container,.vue-devtools__anchor,.vue-devtools-frame,.vue-devtools__anchor-btn{display:none!important}'
  186. })
  187. }
  188. try {
  189. await page.goto(`${origin}/#/m/crm/product?mpTicket=product-fixture`)
  190. await page.getByRole('heading', { name: '产品管理', exact: true }).waitFor()
  191. await page.getByRole('button', { name: `查看产品:${ownProduct.productName}` }).waitFor()
  192. await hideDevtools()
  193. assert.deepEqual(await page.locator('.mp-wv-status-tabs button').allTextContents(), [
  194. '全部',
  195. '上架',
  196. '下架'
  197. ])
  198. const listOrder = await Promise.all(
  199. ['.mp-wv-header', '.mp-wv-status-tabs', '.mp-wv-filter-bar--three', '.mp-crm-expense-card'].map(
  200. async (selector) => (await page.locator(selector).first().boundingBox())?.y ?? -1
  201. )
  202. )
  203. assert.deepEqual(
  204. [...listOrder].sort((a, b) => a - b),
  205. listOrder
  206. )
  207. assert.equal(await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig').count(), 3)
  208. assert.equal(await page.locator('.mp-crm-expense-card .ec-amount').count(), 0)
  209. const firstCard = page.locator('.mp-crm-expense-card').first()
  210. for (const name of ['编辑', '下架', '删除']) {
  211. assert.ok(await firstCard.getByRole('button', { name, exact: true }).isVisible())
  212. }
  213. for (const button of await firstCard.locator('.ec-bottom button').all()) {
  214. const box = await button.boundingBox()
  215. assert.ok(
  216. (box?.width || 0) >= 44 && (box?.height || 0) >= 44,
  217. `表单底栏命中尺寸:${JSON.stringify(box)}`
  218. )
  219. }
  220. assert.ok(
  221. await page
  222. .locator('.mp-crm-expense-card')
  223. .nth(1)
  224. .getByRole('button', { name: '删除', exact: true })
  225. .isDisabled()
  226. )
  227. await page.getByRole('button', { name: '快捷操作' }).click()
  228. for (const name of ['产品分类', '新增产品']) {
  229. const item = page.getByRole('button', { name, exact: true })
  230. assert.ok(await item.isVisible())
  231. const box = await item.boundingBox()
  232. assert.ok((box?.width || 0) >= 44 && (box?.height || 0) >= 44)
  233. }
  234. await page.getByRole('button', { name: '快捷操作' }).click()
  235. const search = page.getByRole('searchbox', { name: '搜索产品' })
  236. await search.fill('甲醇')
  237. await Promise.all([
  238. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  239. search.press('Enter')
  240. ])
  241. assert.equal(lastCall('/web/crm/products/list').body.keyword, '甲醇')
  242. await page.getByText('化工原料 / 醇类', { exact: true }).first().waitFor()
  243. await Promise.all([
  244. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  245. search.fill('')
  246. ])
  247. assert.equal(lastCall('/web/crm/products/list').body.keyword, undefined)
  248. await page.getByRole('button', { name: '全部型号', exact: true }).click()
  249. await Promise.all([
  250. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  251. page.getByRole('button', { name: '桶装(首屏外)', exact: true }).click()
  252. ])
  253. assert.equal(lastCall('/web/crm/products/list').body.model, '桶装(首屏外)')
  254. await page.getByRole('button', { name: '桶装(首屏外)', exact: true }).click()
  255. await Promise.all([
  256. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  257. page.getByRole('button', { name: '全部型号', exact: true }).click()
  258. ])
  259. await page.getByRole('button', { name: '全部分类', exact: true }).click()
  260. await Promise.all([
  261. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  262. page.getByRole('button', { name: '化工原料 / 醇类', exact: true }).click()
  263. ])
  264. assert.equal(lastCall('/web/crm/products/list').body.categoryId, 'category-2')
  265. await Promise.all([
  266. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  267. page.locator('.mp-wv-status-tabs').getByRole('button', { name: '上架', exact: true }).click()
  268. ])
  269. assert.equal(lastCall('/web/crm/products/list').body.statusIn, 'on')
  270. await page.getByRole('button', { name: '更多', exact: true }).click()
  271. const moreSheet = page.getByRole('dialog').filter({ hasText: '更多筛选' })
  272. await moreSheet.locator('input[type="number"]').nth(0).fill('10')
  273. await moreSheet.locator('input[type="number"]').nth(1).fill('30')
  274. await Promise.all([
  275. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  276. moreSheet.getByRole('button', { name: '确定', exact: true }).click()
  277. ])
  278. assert.equal(lastCall('/web/crm/products/list').body.priceMin, 10)
  279. assert.equal(lastCall('/web/crm/products/list').body.priceMax, 30)
  280. assert.equal(await page.locator('.mp-wv-filter-more__dot').count(), 1)
  281. failNextPage2 = true
  282. const loadMore = page.getByRole('button', { name: /加载更多/ })
  283. await Promise.all([
  284. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  285. loadMore.click()
  286. ])
  287. const pageError = page.locator('.mp-product-page-error[role="alert"]')
  288. await pageError.getByText('分页测试失败', { exact: true }).waitFor()
  289. await Promise.all([
  290. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')),
  291. pageError.getByRole('button', { name: '重试加载', exact: true }).click()
  292. ])
  293. const pageTwoCalls = calls.filter(
  294. (item) => item.path === '/web/crm/products/list' && Number(item.body.page) === 2
  295. )
  296. assert.equal(pageTwoCalls.length, 2, '分页失败后重试必须仍请求第 2 页')
  297. await firstCard.getByRole('button', { name: '下架', exact: true }).click()
  298. await Promise.all([
  299. page.waitForResponse((response) => response.url().endsWith('/web/crm/products/update-status')),
  300. page.getByRole('button', { name: '确定', exact: true }).click()
  301. ])
  302. assert.equal(lastCall('/web/crm/products/update-status').body.status, 'off')
  303. await page.getByRole('dialog').waitFor({ state: 'hidden' })
  304. await page.locator('.el-message').last().waitFor({ state: 'hidden' })
  305. await page.evaluate(() => window.scrollTo(0, 0))
  306. assert.equal(await page.locator('.el-overlay').filter({ visible: true }).count(), 0)
  307. await assertNoOverflow(375)
  308. await page.screenshot({ path: `${artifacts}/product-list-375.png`, fullPage: true })
  309. await assertNoOverflow(414)
  310. await page.screenshot({ path: `${artifacts}/product-list-414.png`, fullPage: true })
  311. await page.goto(`${origin}/#/m/crm/product/${ownProduct.id}?mpTicket=product-detail-fixture`)
  312. await page.getByRole('heading', { name: '产品详情', exact: true }).waitFor()
  313. await page.reload()
  314. await page.getByRole('heading', { name: '产品详情', exact: true }).waitFor()
  315. await hideDevtools()
  316. assert.equal(await page.locator('.mp-product-detail-tab').count(), 3)
  317. assert.equal(await page.locator('.status-tag--info').filter({ hasText: '下架' }).count(), 1)
  318. for (const button of await page.locator('.mp-wv-fab-bar button').all()) {
  319. const box = await button.boundingBox()
  320. assert.ok((box?.width || 0) >= 44 && (box?.height || 0) >= 44)
  321. }
  322. await page.getByRole('button', { name: '产品图片', exact: true }).click()
  323. await page.locator('.mp-wv-img-grid__item').click()
  324. await page.locator('.el-image-viewer__wrapper').waitFor()
  325. await page.keyboard.press('Escape')
  326. await page.locator('.el-image-viewer__wrapper').waitFor({ state: 'hidden' })
  327. await assertNoOverflow(375)
  328. await page.screenshot({ path: `${artifacts}/product-detail-375.png`, fullPage: true })
  329. await assertNoOverflow(414)
  330. await page.screenshot({ path: `${artifacts}/product-detail-414.png`, fullPage: true })
  331. const slowRequest = page.waitForRequest((request) => {
  332. if (!request.url().endsWith('/web/crm/products/detail')) return false
  333. try {
  334. return request.postDataJSON()?.id === 'product-slow'
  335. } catch {
  336. return false
  337. }
  338. })
  339. const slowResponse = page.waitForResponse((response) => {
  340. if (!response.url().endsWith('/web/crm/products/detail')) return false
  341. try {
  342. return response.request().postDataJSON()?.id === 'product-slow'
  343. } catch {
  344. return false
  345. }
  346. })
  347. await page.evaluate(() => {
  348. location.hash = '#/m/crm/product/product-slow?mpTicket=product-race-fixture'
  349. })
  350. await slowRequest
  351. const fastResponse = page.waitForResponse((response) => {
  352. if (!response.url().endsWith('/web/crm/products/detail')) return false
  353. try {
  354. return response.request().postDataJSON()?.id === 'product-1'
  355. } catch {
  356. return false
  357. }
  358. })
  359. await page.evaluate(() => {
  360. location.hash = '#/m/crm/product/product-1?mpTicket=product-race-fixture'
  361. })
  362. await fastResponse
  363. await slowResponse
  364. assert.ok(await page.getByText('CP-001', { exact: true }).isVisible())
  365. assert.equal(await page.getByText('CP-SLOW', { exact: true }).count(), 0)
  366. await page.goto(`${origin}/#/m/crm/product/${ownProduct.id}/edit?mpTicket=product-edit-fixture`)
  367. await page.getByRole('heading', { name: '编辑产品', exact: true }).waitFor()
  368. const formBack = page.getByRole('button', { name: '返回产品列表', exact: true })
  369. const formBackBox = await formBack.boundingBox()
  370. assert.ok((formBackBox?.width || 0) >= 44 && (formBackBox?.height || 0) >= 44)
  371. for (const button of await page.locator('.mp-plan-form-footer button').all()) {
  372. const box = await button.boundingBox()
  373. assert.ok(
  374. (box?.width || 0) >= 44 && (box?.height || 0) >= 30 && (box?.height || 0) <= 34,
  375. `表单底栏按钮尺寸(可视高约30px):${JSON.stringify(box)}`
  376. )
  377. const hasIcon = await button.locator('.el-icon').count()
  378. assert.equal(hasIcon, 0, '小程序产品表单底栏按钮不应带 icon')
  379. }
  380. for (const name of ['新增品牌', '新增别名', '重新生成']) {
  381. const btn = page.getByRole('button', { name, exact: true })
  382. assert.ok(await btn.isVisible(), `${name}应可见`)
  383. const box = await btn.boundingBox()
  384. assert.ok(
  385. (box?.height || 0) >= 30 && (box?.height || 0) <= 34,
  386. `${name}可视高约30px:${JSON.stringify(box)}`
  387. )
  388. assert.equal(await btn.locator('.el-icon').count(), 0, `${name}不应带 icon`)
  389. }
  390. const uploadCardBox = await page.locator('.attachment-upload .upload-card').boundingBox()
  391. assert.ok(
  392. Math.abs((uploadCardBox?.width || 0) - 72) <= 1,
  393. `上传卡宽度:${JSON.stringify(uploadCardBox)}`
  394. )
  395. assert.ok(
  396. Math.abs((uploadCardBox?.height || 0) - 72) <= 1,
  397. `上传卡高度:${JSON.stringify(uploadCardBox)}`
  398. )
  399. const uploadInput = page.locator('.attachment-upload input[type="file"]').first()
  400. await Promise.all([
  401. page.waitForResponse((response) => response.url().endsWith('/web/admin/attachment/upload')),
  402. uploadInput.setInputFiles({
  403. name: 'new-product.png',
  404. mimeType: 'image/png',
  405. buffer: Buffer.from('fixture-product-image')
  406. })
  407. ])
  408. await page.locator('.attachment-name[title="new-product.png"]').waitFor({ state: 'attached' })
  409. const uploadDeleteBox = await page.locator('.attachment-compact-delete').last().boundingBox()
  410. assert.ok(
  411. (uploadDeleteBox?.width || 0) >= 44 && (uploadDeleteBox?.height || 0) >= 44,
  412. `上传删除命中尺寸:${JSON.stringify(uploadDeleteBox)}`
  413. )
  414. await page.locator('.attachment-item .attachment-preview').last().click()
  415. const uploadPreview = page.getByRole('dialog', { name: 'new-product.png' })
  416. await uploadPreview.waitFor()
  417. await uploadPreview.locator('.el-dialog__headerbtn').click()
  418. await uploadPreview.waitFor({ state: 'hidden' })
  419. forbidNextList = true
  420. await page.goto(`${origin}/#/m/crm/product?mpTicket=product-forbidden-fixture`)
  421. await page.getByRole('heading', { name: '产品管理', exact: true }).waitFor()
  422. assert.ok(await page.getByRole('button', { name: '返回工作台', exact: true }).isVisible())
  423. await page.locator('.mp-wv-empty').getByText('无权限', { exact: true }).waitFor()
  424. assert.equal(await page.locator('.el-overlay').filter({ visible: true }).count(), 0)
  425. assert.deepEqual(pageErrors, [])
  426. assert.deepEqual(unknownPaths, [], `存在未模拟 API:${unknownPaths.join(', ')}`)
  427. console.log(`product Playwright: PASS\nscreenshots: ${artifacts}`)
  428. } finally {
  429. await browser.close()
  430. }