| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- import assert from 'node:assert/strict'
- import { mkdir } from 'node:fs/promises'
- import { chromium } from 'playwright'
- // 本地 H5 交互回归:所有接口均由 fixture 拦截,不写真实业务库。
- const origin = process.env.CONTRACT_TEST_ORIGIN || 'http://localhost:3006'
- const artifacts = '/tmp/shanxia-contract-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
- })
- await context.addInitScript(() => {
- const start = () => {
- const root = document.documentElement
- if (!root) return
- const hide = () => {
- const el = document.getElementById('__vue-devtools-container__')
- if (el) el.style.setProperty('display', 'none', 'important')
- }
- hide()
- new MutationObserver(hide).observe(root, { childList: true, subtree: true })
- }
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start)
- else start()
- })
- const page = await context.newPage()
- page.setDefaultTimeout(7000)
- const pageErrors = []
- const calls = []
- page.on('pageerror', (error) => pageErrors.push(error.message))
- const baseContract = {
- id: 'contract-1',
- contractNo: 'HT-20261017-001',
- contractName: '年度化工原料销售合同',
- customerId: 'customer-1',
- customerName: '宜昌测试客户',
- contactName: '张经理',
- contactMobile: '13800138000',
- contractType: 'sale',
- amount: 128600,
- freight: 500,
- barrelDeposit: 1000,
- amountWithTax: 130100,
- signDate: '2026-10-17',
- startDate: '2026-10-18',
- endDate: '2027-10-17',
- ownerUserId: 'user-1',
- ownerUserName: '测试销售',
- status: 'draft',
- canApprove: true,
- partyA: '宜昌测试客户',
- partyAContact: '张经理',
- partyAMobile: '13800138000',
- partyB: '山峡化工',
- partyBContact: '李经理',
- partyBMobile: '13900139000',
- paymentTerms: '月结 30 天',
- deliveryTerms: '送货上门',
- contractTerms: '按约定履行',
- attachmentFiles: JSON.stringify([{ ossId: '/fixtures/合同扫描件.pdf', category: 'scan' }]),
- remark: 'fixture 合同',
- createBy: 'user-1',
- createTime: '2026-10-17 09:00:00',
- products: [
- {
- id: 'line-1',
- standardProductName: '工业甲醇',
- quantity: 10,
- unit: '吨',
- unitPrice: 12000,
- amount: 120000
- }
- ]
- }
- const contracts = [
- baseContract,
- {
- ...baseContract,
- id: 'contract-2',
- contractNo: 'HT-20261017-002',
- contractName: '已审批合同',
- status: 'approved',
- canApprove: false
- },
- {
- ...baseContract,
- id: 'contract-fail',
- contractNo: 'HT-20261017-003',
- contractName: '审批失败样本',
- status: 'approving',
- canApprove: true
- }
- ]
- let approvals = [
- {
- id: 'approval-1',
- contractId: baseContract.id,
- step: 1,
- approverId: 'user-before',
- approverName: '前置审批人',
- approvalResult: 'pending',
- approvalOpinion: '',
- createTime: '2026-10-17 10:00:00'
- },
- {
- id: 'approval-2',
- contractId: baseContract.id,
- step: 2,
- approverId: 'user-1',
- approverName: '测试审批人',
- approvalResult: 'pending',
- approvalOpinion: '',
- createTime: '2026-10-17 10:30:00'
- }
- ]
- let approvalEntry = false
- let forbiddenMode = ''
- const dict = {
- crm_contract_status: [
- { value: 'draft', label: '草稿', tagType: 'info' },
- { value: 'approving', label: '待审批', tagType: 'warning' },
- { value: 'approved', label: '已审批', tagType: 'success' },
- { value: 'invalid', label: '已作废', tagType: 'danger' }
- ],
- crm_contract_type: [
- { value: 'sale', label: '销售合同', tagType: 'primary' },
- { value: 'purchase', label: '采购合同', tagType: 'info' }
- ]
- }
- await context.addInitScript(() => {
- window.wx = {
- miniProgram: {
- navigateBack() {},
- reLaunch() {},
- getEnv(callback) {
- callback({ miniprogram: true })
- }
- }
- }
- })
- 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 = Object.fromEntries(url.searchParams)
- try {
- body = { ...body, ...(request.postDataJSON() || {}) }
- } catch {
- // multipart 上传不参与 body 精确断言。
- }
- calls.push({ path, body })
- let data = {}
- let code = 200
- let msg = 'ok'
- if (path === '/web/auth/mp-webview-exchange') {
- const ticket = String(body.ticket || '')
- approvalEntry = ticket.includes('approve')
- forbiddenMode = ticket.includes('forbidden-list')
- ? 'list'
- : ticket.includes('forbidden-detail')
- ? 'detail'
- : ''
- data = {
- token: 'fixture-token',
- refreshToken: 'fixture-refresh',
- userInfo: {
- id: 'user-1',
- userId: 'user-1',
- userName: '测试用户',
- role: 'admin',
- roles: ['admin']
- }
- }
- } else if (path === '/web/admin/dict-data/all') {
- data = dict
- } else if (path === '/web/admin/users/options') {
- data = [{ value: 'user-1', label: '测试审批人', userNo: 'approver' }]
- } else if (path === '/web/crm/contract/list') {
- if (forbiddenMode === 'list') {
- code = 403
- msg = '无权限访问合同列表'
- } else {
- const list = body.approvedByUserId
- ? contracts.filter((item) => item.status === 'approved')
- : approvalEntry
- ? contracts.map((item) => ({
- ...item,
- status: item.id === 'contract-2' ? 'approved' : 'approving'
- }))
- : contracts
- data = { list, total: list.length }
- }
- } else if (/^\/web\/crm\/contract\/contract-(?:1|2|fail)$/.test(path)) {
- if (forbiddenMode === 'detail') {
- code = 403
- msg = '无权限访问合同详情'
- } else {
- const id = decodeURIComponent(path.split('/').pop())
- const row = contracts.find((item) => item.id === id) || baseContract
- data = {
- ...row,
- status: approvalEntry
- ? approvals.some((item) => item.approvalResult === 'pending')
- ? 'approving'
- : 'approved'
- : row.status
- }
- }
- } else if (/^\/web\/crm\/contract\/approval\/flow\//.test(path)) {
- data = { list: approvals }
- } else if (path === '/web/crm/contract-approver-setting/get') {
- data = { approverIds: ['user-1'], status: '0' }
- } else if (path === '/web/crm/contract/batchSubmitApproval') {
- data = body.ids?.length || 0
- } else if (path === '/web/crm/contract/approval') {
- if (body.id === 'contract-fail') {
- await route.abort('failed')
- return
- } else {
- approvals = approvals.map((item) => ({
- ...item,
- approvalResult: body.approvalResult,
- approvalOpinion: body.approvalOpinion,
- approvalTime: '2026-10-17 11:00:00'
- }))
- data = true
- }
- } else if (path === '/web/admin/attachment/upload') {
- data = { id: 'upload-1', fileName: '补充扫描件.png', fileUrl: '/fixtures/补充扫描件.png' }
- } else if (path === '/web/crm/contract/uploadAttachments') {
- data = true
- } else if (path === '/web/crm/contract/invalid') {
- data = true
- }
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ code, msg, data })
- })
- })
- const click = (name) =>
- page
- .getByRole('button', { name, exact: true })
- .or(page.getByRole('tab', { name, exact: true }))
- .filter({ visible: true })
- .first()
- .click()
- 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 不应横向溢出`
- )
- }
- function boxesOverlap(a, b) {
- return !(
- a.x + a.width <= b.x ||
- b.x + b.width <= a.x ||
- a.y + a.height <= b.y ||
- b.y + b.height <= a.y
- )
- }
- try {
- await page.goto(`${origin}/#/m/crm/contract?mpTicket=contract-forbidden-list`)
- await page.getByText('无权限', { exact: true }).waitFor()
- await page.goto(
- `${origin}/#/m/crm/contract/contract-1?from=manage&mpTicket=contract-forbidden-detail`
- )
- await page.locator('.mp-wv-empty').getByText('无权限访问合同详情', { exact: true }).waitFor()
- await page.goto(`${origin}/#/m/crm/contract?mpTicket=contract-manage-fixture`)
- await page.getByRole('heading', { name: '合同管理', exact: true }).waitFor()
- await page.getByRole('button', { name: `查看合同:${baseContract.contractName}` }).waitFor()
- const listOrder = await Promise.all(
- ['.mp-wv-header', '.mp-customer-view-tabs', '.mp-customer-dd', '.mp-wv-flat-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-customer-dd .van-dropdown-menu__item').count(), 3)
- assert.equal(await page.locator('.mp-wv-speed-dial').count(), 1)
- const manageCards = page.locator('.mp-wv-flat-card')
- assert.equal(await manageCards.nth(0).locator('.mp-wv-card-actions').isVisible(), true)
- assert.equal(await manageCards.nth(1).locator('.mp-wv-card-actions').count(), 0)
- assert.equal(await manageCards.nth(2).locator('.mp-wv-card-actions').count(), 0)
- await page.locator('.van-dropdown-menu__title', { hasText: '全部类型' }).click()
- await Promise.all([
- page.waitForResponse((response) =>
- new URL(response.url()).pathname.endsWith('/web/crm/contract/list')
- ),
- page.locator('.van-dropdown-item__option', { hasText: '销售合同' }).first().click()
- ])
- assert.equal(lastCall('/web/crm/contract/list').body.contractType, 'sale')
- await page.locator('.van-dropdown-menu__title', { hasText: '更多' }).click()
- const filterSheet = page.getByRole('dialog').filter({ hasText: '更多筛选' })
- await filterSheet.waitFor()
- await filterSheet.locator('input[type="search"]').fill('年度')
- await filterSheet.getByText('签订日起', { exact: true }).click()
- await page.locator('.van-picker__confirm').click()
- await filterSheet.getByText('签订日止', { exact: true }).click()
- await page.locator('.van-picker__confirm').click()
- await filterSheet.locator('.van-field').filter({ hasText: '金额最低' }).locator('input').fill('100')
- await filterSheet.locator('.van-field').filter({ hasText: '金额最高' }).locator('input').fill('200000')
- await Promise.all([
- page.waitForResponse((response) =>
- new URL(response.url()).pathname.endsWith('/web/crm/contract/list')
- ),
- filterSheet.getByRole('button', { name: '确定', exact: true }).click()
- ])
- const moreBody = lastCall('/web/crm/contract/list').body
- assert.equal(moreBody.keyword, '年度')
- assert.match(moreBody.signDateBegin, /^\d{4}-\d{2}-\d{2}$/)
- assert.match(moreBody.signDateEnd, /^\d{4}-\d{2}-\d{2}$/)
- assert.equal(Number(moreBody.minAmount), 100)
- assert.equal(Number(moreBody.maxAmount), 200000)
- const searchInput = page.getByRole('searchbox', { name: '搜索合同' })
- await Promise.all([
- page.waitForResponse((response) =>
- new URL(response.url()).pathname.endsWith('/web/crm/contract/list')
- ),
- searchInput.fill('')
- ])
- assert.equal('keyword' in lastCall('/web/crm/contract/list').body, false)
- await click('多选')
- assert.ok(await page.locator('.mp-wv-batch-bar').isVisible())
- const selectors = page.locator('.expense-card__select')
- await selectors.nth(0).click()
- await selectors.nth(1).click()
- await click('提交审批')
- const submitSheet = page.getByRole('dialog').filter({ hasText: '提交审批' })
- await submitSheet.waitFor()
- assert.ok(await submitSheet.getByText('已跳过 1 条非草稿', { exact: false }).isVisible())
- await Promise.all([
- page.waitForResponse((response) =>
- response.url().endsWith('/web/crm/contract/batchSubmitApproval')
- ),
- submitSheet.getByRole('button', { name: '确认提交', exact: true }).click()
- ])
- assert.deepEqual(lastCall('/web/crm/contract/batchSubmitApproval').body.ids, ['contract-1'])
- const submitSuccess = page.getByText('成功提交 1 条,跳过 1 条', { exact: true })
- await submitSuccess.waitFor()
- await submitSuccess.waitFor({ state: 'hidden' })
- await click('快捷操作')
- assert.ok(await page.getByRole('button', { name: '新增合同', exact: true }).isVisible())
- await click('快捷操作')
- await page.screenshot({ path: `${artifacts}/manage-list-375.png`, fullPage: true })
- await assertNoOverflow(375)
- const manageDetailUrl = `${origin}/#/m/crm/contract/contract-1?from=manage&mpTicket=contract-manage-direct`
- const detailCallsBefore = calls.filter(
- (item) => item.path === '/web/crm/contract/contract-1'
- ).length
- await page.goto(manageDetailUrl)
- await page.getByRole('heading', { name: '合同详情', exact: true }).waitFor()
- await page.reload()
- await page.getByRole('heading', { name: '合同详情', exact: true }).waitFor()
- assert.ok(
- calls.filter((item) => item.path === '/web/crm/contract/contract-1').length >=
- detailCallsBefore + 2
- )
- assert.equal(await page.locator('.mp-wv-header').getByText(baseContract.contractName).count(), 0)
- assert.equal(await page.locator('.mp-wv-header__sub').count(), 0)
- assert.ok(await page.getByRole('button', { name: '提交审批', exact: true }).isVisible())
- assert.ok(await page.getByRole('button', { name: '编辑', exact: true }).isVisible())
- assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0)
- await click('产品明细')
- await page.getByText('工业甲醇', { exact: true }).waitFor()
- await click('审批信息')
- await page.locator('.mp-crm-approval-timeline').waitFor()
- await click('附件')
- const attachment = page.locator('.mp-crm-att-grid .att-item').first()
- const attachmentBox = await attachment.boundingBox()
- assert.equal(Math.round(attachmentBox?.width || 0), 72)
- assert.equal(Math.round(attachmentBox?.height || 0), 72)
- const previewBox = await attachment.locator('.att-preview').boundingBox()
- const removeBox = await attachment.locator('.att-remove').boundingBox()
- const addBox = await page.locator('.mp-crm-att-grid .att-add').boundingBox()
- const attachmentGeometry = { attachmentBox, previewBox, removeBox, addBox }
- console.log(`contract attachment geometry: ${JSON.stringify(attachmentGeometry)}`)
- assert.equal(
- Math.round(previewBox?.width || 0),
- 72,
- `附件预览宽度必须为 72px:${JSON.stringify(previewBox)}`
- )
- assert.equal(
- Math.round(previewBox?.height || 0),
- 72,
- `附件预览高度必须为 72px:${JSON.stringify(previewBox)}`
- )
- assert.ok(
- (removeBox?.width || 0) >= 44 && (removeBox?.height || 0) >= 44,
- `删除附件命中区必须不小于 44px:${JSON.stringify(removeBox)}`
- )
- assert.ok(
- (addBox?.width || 0) >= 44 && (addBox?.height || 0) >= 44,
- `添加附件命中区必须不小于 44px:${JSON.stringify(addBox)}`
- )
- assert.equal(
- boxesOverlap(removeBox, addBox),
- false,
- `删除与添加命中区不得重叠:${JSON.stringify({ removeBox, addBox })}`
- )
- await page.screenshot({ path: `${artifacts}/manage-detail-375.png`, fullPage: true })
- await click('更多')
- const uploadScan = page.getByRole('button', { name: '上传扫描件', exact: true })
- await uploadScan.waitFor()
- const uploadScanBox = await uploadScan.boundingBox()
- assert.ok(uploadScanBox.y >= 0 && uploadScanBox.y + uploadScanBox.height <= 812)
- await uploadScan.click()
- await page.locator('input[type="file"]').setInputFiles({
- name: '补充扫描件.png',
- mimeType: 'image/png',
- buffer: Buffer.from('fixture')
- })
- await page.waitForResponse((response) =>
- response.url().endsWith('/web/crm/contract/uploadAttachments')
- )
- assert.equal(lastCall('/web/crm/contract/uploadAttachments').body.id, 'contract-1')
- assert.equal(lastCall('/web/crm/contract/uploadAttachments').body.attachments.length, 2)
- await page.goto(`${origin}/#/m/crm/contract/approval?mpTicket=contract-approve-fixture`)
- await page.getByRole('heading', { name: '合同审批', exact: true }).waitFor()
- await page.getByRole('button', { name: `查看合同:${baseContract.contractName}` }).waitFor()
- assert.equal(await page.locator('.mp-wv-speed-dial').count(), 0)
- assert.equal(lastCall('/web/crm/contract/list').body.pendingApprovalUserId, 'user-1')
- const approveCards = page.locator('.mp-wv-flat-card')
- assert.equal(await approveCards.nth(0).locator('.mp-wv-card-actions').isVisible(), true)
- assert.equal(await approveCards.nth(1).locator('.mp-wv-card-actions').count(), 0)
- assert.equal(await approveCards.nth(2).locator('.mp-wv-card-actions').isVisible(), true)
- await click('多选')
- const approveSelectors = page.locator('.expense-card__select')
- await approveSelectors.nth(0).click()
- await approveSelectors.nth(1).click()
- await approveSelectors.nth(2).click()
- await click('通过')
- const batchApproveSheet = page.getByRole('dialog').filter({ hasText: '批量通过' })
- await batchApproveSheet.waitFor()
- assert.ok(await batchApproveSheet.getByText('已跳过 1 条不可审批', { exact: false }).isVisible())
- await batchApproveSheet.getByRole('button', { name: '确认通过', exact: true }).click()
- await page.waitForTimeout(100)
- const partialMessages = await page.locator('.el-message').allTextContents()
- assert.ok(
- partialMessages.some((text) => text.includes('成功通过 1 条,失败 1 条,跳过 1 条')),
- `批量部分成功提示缺失:${partialMessages.join(' | ')}`
- )
- assert.equal(calls.filter((item) => item.path === '/web/crm/contract/approval').length, 2)
- assert.equal(await page.locator('.mp-wv-batch-bar').count(), 0)
- await page.waitForFunction(() => document.querySelectorAll('.el-message').length === 0)
- approvals = approvals.map((item) => ({
- ...item,
- approvalResult: 'pending',
- approvalOpinion: '',
- approvalTime: ''
- }))
- await page.goto(
- `${origin}/#/m/crm/contract/contract-1?from=approve&mpTicket=contract-approve-direct`
- )
- await page.getByRole('heading', { name: '合同详情', exact: true }).waitFor()
- assert.match(page.url(), /from=approve/)
- assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0)
- assert.equal(await page.getByRole('button', { name: '驳回', exact: true }).count(), 0)
- assert.equal(await page.getByRole('button', { name: '提交审批', exact: true }).count(), 0)
- assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0)
- approvals[0] = {
- ...approvals[0],
- approvalResult: 'approved',
- approvalOpinion: '前置审批已通过',
- approvalTime: '2026-10-17 10:20:00'
- }
- await page.reload()
- await page.getByRole('button', { name: '通过', exact: true }).waitFor()
- assert.ok(await page.getByRole('button', { name: '驳回', exact: true }).isVisible())
- assert.equal(await page.getByRole('button', { name: '提交审批', exact: true }).count(), 0)
- assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0)
- await click('通过')
- const approveSheet = page.getByRole('dialog').filter({ hasText: '通过合同' })
- await approveSheet.waitFor()
- await approveSheet.getByRole('button', { name: '确认通过', exact: true }).click()
- const confirmDialog = page.locator('.van-dialog').filter({ hasText: '确认要通过该合同吗?' })
- await confirmDialog.getByRole('button', { name: '确认', exact: true }).click()
- await confirmDialog.waitFor({ state: 'hidden' })
- assert.equal(lastCall('/web/crm/contract/approval').body.id, 'contract-1')
- assert.equal(lastCall('/web/crm/contract/approval').body.approvalResult, 'approved')
- await page.waitForFunction(() => document.querySelectorAll('.el-message').length === 0)
- await assertNoOverflow(414)
- await page.screenshot({ path: `${artifacts}/approve-detail-414.png`, fullPage: true })
- await page.getByRole('button', { name: '返回合同审批', exact: true }).click()
- await page.getByRole('heading', { name: '合同审批', exact: true }).waitFor()
- await Promise.all([
- page.waitForResponse((response) =>
- new URL(response.url()).pathname.endsWith('/web/crm/contract/list')
- ),
- click('已审批')
- ])
- assert.equal(lastCall('/web/crm/contract/list').body.approvedByUserId, 'user-1')
- await page.screenshot({ path: `${artifacts}/approve-list-414.png`, fullPage: true })
- assert.deepEqual(pageErrors, [])
- console.log(
- JSON.stringify({
- result: 'PASS',
- assertions:
- '管理/审批列表骨架、真实筛选、金额卡、动作栏显隐、Speed Dial差异、多选批量底栏、批量提交、批量审批部分成功、详情直达/刷新、列表与详情403、两步审批门禁、入口权限差异、固定详情头、Tab、时间轴、72px附件与不重叠命中区、附件上传、单条审批、375/414布局',
- apiCalls: calls.length,
- screenshots: artifacts
- })
- )
- } catch (error) {
- await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true })
- console.error(await page.locator('body').innerText())
- console.error('Page errors:', pageErrors)
- console.error('API calls:', calls)
- throw error
- } finally {
- await browser.close()
- }
|