Files
shanxia2.0/web-admin/scripts/test-mp-crm-product.mjs
T
2026-10-19 18:28:02 +08:00

458 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}