458 lines
17 KiB
JavaScript
458 lines
17 KiB
JavaScript
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()
|
||
}
|