import assert from 'node:assert/strict' import { mkdir } from 'node:fs/promises' import { chromium } from 'playwright' // 本地 H5 交互回归:所有 API 均由 fixture 拦截,不写真实业务库。 const origin = process.env.PRODUCT_TEST_ORIGIN || 'http://localhost:3006' const artifacts = '/tmp/shanxia-product-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 = [] const calls = [] const unknownPaths = [] page.on('pageerror', (error) => pageErrors.push(error.message)) const image = `${origin}/fixture-product.svg` const ownProduct = { id: 'product-1', productNo: 'CP-001', productName: '工业甲醇', namePinyinAbbr: 'GYJC', categoryId: 'category-2', categoryName: '醇类', model: '优级品', barcode: '690000000001', taxRate: 13, stock: 20, lowStock: 5, status: 'on', isToxic: 1, isExplosive: 0, description: '产品说明', remark: 'fixture 产品', images: image, brands: [{ id: 'brand-1', brandName: '山峡牌', status: 'on' }], aliases: [{ id: 'alias-1', aliasName: '木醇' }], createBy: 'user-1', createTime: '2026-10-17 09:00:00' } const foreignProduct = { ...ownProduct, id: 'product-foreign', productNo: 'CP-002', productName: '外部创建产品', status: 'off', createBy: 'user-2' } let failNextPage2 = false let forbidNextList = false let failNextCategory = true await context.addInitScript(() => { window.wx = { miniProgram: { navigateBack() {}, reLaunch() {}, getEnv(callback) { callback({ miniprogram: true }) } } } }) await context.route('**/fixture-product.svg', (route) => route.fulfill({ contentType: 'image/svg+xml', body: '产品图片' }) ) await context.route('**/api/**', async (route) => { const request = route.request() const url = new URL(request.url()) if (!url.pathname.startsWith('/api/')) return route.continue() const path = url.pathname.replace(/^\/api/, '') let body = {} try { body = request.postDataJSON() || {} } catch { // 上传类请求不参与 JSON body 断言。 } calls.push({ path, body }) let code = 200 let msg = 'ok' let data = {} if (path === '/web/auth/mp-webview-exchange') { data = { token: 'fixture-token', refreshToken: 'fixture-refresh', userInfo: { id: 'user-1', userId: 'user-1', userName: '测试用户', roles: ['admin'] } } } else if (path === '/web/admin/dict-data/all') { data = { crm_product_status: [ { value: 'on', label: '上架', tagType: 'success' }, { value: 'off', label: '下架', tagType: 'info' } ] } } else if (path === '/web/crm/product-categories/tree') { if (failNextCategory) { failNextCategory = false code = 500 msg = '分类树测试失败' } else { data = [ { id: 'category-1', categoryName: '化工原料', children: [{ id: 'category-2', categoryName: '醇类', children: [] }] } ] } } else if (path === '/web/crm/products/column-facet-values') { data = { list: [ { value: '优级品', label: '优级品', count: 2 }, { value: '桶装(首屏外)', label: '桶装(首屏外)', count: 1 } ] } } else if (path === '/web/crm/products/list') { if (forbidNextList) { forbidNextList = false code = 403 msg = '无权限' } else if (Number(body.page) === 2 && failNextPage2) { failNextPage2 = false code = 500 msg = '分页测试失败' } else { data = { list: Number(body.page) === 2 ? [{ ...ownProduct, id: 'product-3', productNo: 'CP-003' }] : [ownProduct, foreignProduct], total: 22 } } } else if (path === '/web/crm/products/detail') { if (body.id === 'product-slow') { await new Promise((resolve) => setTimeout(resolve, 450)) data = { ...ownProduct, id: 'product-slow', productNo: 'CP-SLOW', productName: '迟到的旧产品响应' } } else { data = body.id === foreignProduct.id ? foreignProduct : ownProduct } } else if (path === '/web/crm/products/update-status') { ownProduct.status = body.status data = true } else if (path === '/web/crm/products/delete') { data = true } else if (path === '/web/admin/attachment/upload') { data = { id: 'upload-1', fileName: 'new-product.png', fileUrl: image, fileSize: 16, fileType: 'image/png' } } else { unknownPaths.push(path) code = 500 msg = `未模拟 API: ${path}` } await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code, msg, data }) }) }) const lastCall = (path) => calls.findLast((item) => item.path === path) async function assertNoOverflow(width) { await page.setViewportSize({ width, height: 812 }) assert.equal( await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `页面在 ${width}px 不应横向溢出` ) } async function hideDevtools() { await page.addStyleTag({ content: 'vite-plugin-vue-devtools,#vue-devtools-container,.vue-devtools__anchor,.vue-devtools-frame,.vue-devtools__anchor-btn{display:none!important}' }) } try { await page.goto(`${origin}/#/m/crm/product?mpTicket=product-fixture`) await page.getByRole('heading', { name: '产品管理', exact: true }).waitFor() await page.getByRole('button', { name: `查看产品:${ownProduct.productName}` }).waitFor() await hideDevtools() assert.deepEqual(await page.locator('.mp-wv-status-tabs button').allTextContents(), [ '全部', '上架', '下架' ]) const listOrder = await Promise.all( ['.mp-wv-header', '.mp-wv-status-tabs', '.mp-wv-filter-bar--three', '.mp-crm-expense-card'].map( async (selector) => (await page.locator(selector).first().boundingBox())?.y ?? -1 ) ) assert.deepEqual( [...listOrder].sort((a, b) => a - b), listOrder ) assert.equal(await page.locator('.mp-wv-filter-bar--three .mp-wv-filter-trig').count(), 3) assert.equal(await page.locator('.mp-crm-expense-card .ec-amount').count(), 0) const firstCard = page.locator('.mp-crm-expense-card').first() for (const name of ['编辑', '下架', '删除']) { assert.ok(await firstCard.getByRole('button', { name, exact: true }).isVisible()) } for (const button of await firstCard.locator('.ec-bottom button').all()) { const box = await button.boundingBox() assert.ok( (box?.width || 0) >= 44 && (box?.height || 0) >= 44, `表单底栏命中尺寸:${JSON.stringify(box)}` ) } assert.ok( await page .locator('.mp-crm-expense-card') .nth(1) .getByRole('button', { name: '删除', exact: true }) .isDisabled() ) await page.getByRole('button', { name: '快捷操作' }).click() for (const name of ['产品分类', '新增产品']) { const item = page.getByRole('button', { name, exact: true }) assert.ok(await item.isVisible()) const box = await item.boundingBox() assert.ok((box?.width || 0) >= 44 && (box?.height || 0) >= 44) } await page.getByRole('button', { name: '快捷操作' }).click() const search = page.getByRole('searchbox', { name: '搜索产品' }) await search.fill('甲醇') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), search.press('Enter') ]) assert.equal(lastCall('/web/crm/products/list').body.keyword, '甲醇') await page.getByText('化工原料 / 醇类', { exact: true }).first().waitFor() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), search.fill('') ]) assert.equal(lastCall('/web/crm/products/list').body.keyword, undefined) await page.getByRole('button', { name: '全部型号', exact: true }).click() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), page.getByRole('button', { name: '桶装(首屏外)', exact: true }).click() ]) assert.equal(lastCall('/web/crm/products/list').body.model, '桶装(首屏外)') await page.getByRole('button', { name: '桶装(首屏外)', exact: true }).click() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), page.getByRole('button', { name: '全部型号', exact: true }).click() ]) await page.getByRole('button', { name: '全部分类', exact: true }).click() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), page.getByRole('button', { name: '化工原料 / 醇类', exact: true }).click() ]) assert.equal(lastCall('/web/crm/products/list').body.categoryId, 'category-2') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), page.locator('.mp-wv-status-tabs').getByRole('button', { name: '上架', exact: true }).click() ]) assert.equal(lastCall('/web/crm/products/list').body.statusIn, 'on') await page.getByRole('button', { name: '更多', exact: true }).click() const moreSheet = page.getByRole('dialog').filter({ hasText: '更多筛选' }) await moreSheet.locator('input[type="number"]').nth(0).fill('10') await moreSheet.locator('input[type="number"]').nth(1).fill('30') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), moreSheet.getByRole('button', { name: '确定', exact: true }).click() ]) assert.equal(lastCall('/web/crm/products/list').body.priceMin, 10) assert.equal(lastCall('/web/crm/products/list').body.priceMax, 30) assert.equal(await page.locator('.mp-wv-filter-more__dot').count(), 1) failNextPage2 = true const loadMore = page.getByRole('button', { name: /加载更多/ }) await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), loadMore.click() ]) const pageError = page.locator('.mp-product-page-error[role="alert"]') await pageError.getByText('分页测试失败', { exact: true }).waitFor() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/list')), pageError.getByRole('button', { name: '重试加载', exact: true }).click() ]) const pageTwoCalls = calls.filter( (item) => item.path === '/web/crm/products/list' && Number(item.body.page) === 2 ) assert.equal(pageTwoCalls.length, 2, '分页失败后重试必须仍请求第 2 页') await firstCard.getByRole('button', { name: '下架', exact: true }).click() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/products/update-status')), page.getByRole('button', { name: '确定', exact: true }).click() ]) assert.equal(lastCall('/web/crm/products/update-status').body.status, 'off') await page.getByRole('dialog').waitFor({ state: 'hidden' }) await page.locator('.el-message').last().waitFor({ state: 'hidden' }) await page.evaluate(() => window.scrollTo(0, 0)) assert.equal(await page.locator('.el-overlay').filter({ visible: true }).count(), 0) await assertNoOverflow(375) await page.screenshot({ path: `${artifacts}/product-list-375.png`, fullPage: true }) await assertNoOverflow(414) await page.screenshot({ path: `${artifacts}/product-list-414.png`, fullPage: true }) await page.goto(`${origin}/#/m/crm/product/${ownProduct.id}?mpTicket=product-detail-fixture`) await page.getByRole('heading', { name: '产品详情', exact: true }).waitFor() await page.reload() await page.getByRole('heading', { name: '产品详情', exact: true }).waitFor() await hideDevtools() assert.equal(await page.locator('.mp-product-detail-tab').count(), 3) assert.equal(await page.locator('.status-tag--info').filter({ hasText: '下架' }).count(), 1) for (const button of await page.locator('.mp-wv-fab-bar button').all()) { const box = await button.boundingBox() assert.ok((box?.width || 0) >= 44 && (box?.height || 0) >= 44) } await page.getByRole('button', { name: '产品图片', exact: true }).click() await page.locator('.mp-wv-img-grid__item').click() await page.locator('.el-image-viewer__wrapper').waitFor() await page.keyboard.press('Escape') await page.locator('.el-image-viewer__wrapper').waitFor({ state: 'hidden' }) await assertNoOverflow(375) await page.screenshot({ path: `${artifacts}/product-detail-375.png`, fullPage: true }) await assertNoOverflow(414) await page.screenshot({ path: `${artifacts}/product-detail-414.png`, fullPage: true }) const slowRequest = page.waitForRequest((request) => { if (!request.url().endsWith('/web/crm/products/detail')) return false try { return request.postDataJSON()?.id === 'product-slow' } catch { return false } }) const slowResponse = page.waitForResponse((response) => { if (!response.url().endsWith('/web/crm/products/detail')) return false try { return response.request().postDataJSON()?.id === 'product-slow' } catch { return false } }) await page.evaluate(() => { location.hash = '#/m/crm/product/product-slow?mpTicket=product-race-fixture' }) await slowRequest const fastResponse = page.waitForResponse((response) => { if (!response.url().endsWith('/web/crm/products/detail')) return false try { return response.request().postDataJSON()?.id === 'product-1' } catch { return false } }) await page.evaluate(() => { location.hash = '#/m/crm/product/product-1?mpTicket=product-race-fixture' }) await fastResponse await slowResponse assert.ok(await page.getByText('CP-001', { exact: true }).isVisible()) assert.equal(await page.getByText('CP-SLOW', { exact: true }).count(), 0) await page.goto(`${origin}/#/m/crm/product/${ownProduct.id}/edit?mpTicket=product-edit-fixture`) await page.getByRole('heading', { name: '编辑产品', exact: true }).waitFor() const formBack = page.getByRole('button', { name: '返回产品列表', exact: true }) const formBackBox = await formBack.boundingBox() assert.ok((formBackBox?.width || 0) >= 44 && (formBackBox?.height || 0) >= 44) for (const button of await page.locator('.mp-plan-form-footer button').all()) { const box = await button.boundingBox() assert.ok( (box?.width || 0) >= 44 && (box?.height || 0) >= 30 && (box?.height || 0) <= 34, `表单底栏按钮尺寸(可视高约30px):${JSON.stringify(box)}` ) const hasIcon = await button.locator('.el-icon').count() assert.equal(hasIcon, 0, '小程序产品表单底栏按钮不应带 icon') } for (const name of ['新增品牌', '新增别名', '重新生成']) { const btn = page.getByRole('button', { name, exact: true }) assert.ok(await btn.isVisible(), `${name}应可见`) const box = await btn.boundingBox() assert.ok( (box?.height || 0) >= 30 && (box?.height || 0) <= 34, `${name}可视高约30px:${JSON.stringify(box)}` ) assert.equal(await btn.locator('.el-icon').count(), 0, `${name}不应带 icon`) } const uploadCardBox = await page.locator('.attachment-upload .upload-card').boundingBox() assert.ok( Math.abs((uploadCardBox?.width || 0) - 72) <= 1, `上传卡宽度:${JSON.stringify(uploadCardBox)}` ) assert.ok( Math.abs((uploadCardBox?.height || 0) - 72) <= 1, `上传卡高度:${JSON.stringify(uploadCardBox)}` ) const uploadInput = page.locator('.attachment-upload input[type="file"]').first() await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/admin/attachment/upload')), uploadInput.setInputFiles({ name: 'new-product.png', mimeType: 'image/png', buffer: Buffer.from('fixture-product-image') }) ]) await page.locator('.attachment-name[title="new-product.png"]').waitFor({ state: 'attached' }) const uploadDeleteBox = await page.locator('.attachment-compact-delete').last().boundingBox() assert.ok( (uploadDeleteBox?.width || 0) >= 44 && (uploadDeleteBox?.height || 0) >= 44, `上传删除命中尺寸:${JSON.stringify(uploadDeleteBox)}` ) await page.locator('.attachment-item .attachment-preview').last().click() const uploadPreview = page.getByRole('dialog', { name: 'new-product.png' }) await uploadPreview.waitFor() await uploadPreview.locator('.el-dialog__headerbtn').click() await uploadPreview.waitFor({ state: 'hidden' }) forbidNextList = true await page.goto(`${origin}/#/m/crm/product?mpTicket=product-forbidden-fixture`) await page.getByRole('heading', { name: '产品管理', exact: true }).waitFor() assert.ok(await page.getByRole('button', { name: '返回工作台', exact: true }).isVisible()) await page.locator('.mp-wv-empty').getByText('无权限', { exact: true }).waitFor() assert.equal(await page.locator('.el-overlay').filter({ visible: true }).count(), 0) assert.deepEqual(pageErrors, []) assert.deepEqual(unknownPaths, [], `存在未模拟 API:${unknownPaths.join(', ')}`) console.log(`product Playwright: PASS\nscreenshots: ${artifacts}`) } finally { await browser.close() }