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() }