| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- 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: '<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>'
- })
- )
- 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()
- }
|