import assert from 'node:assert/strict' import { mkdir, readFile } from 'node:fs/promises' import { chromium, webkit, devices } from 'playwright' // 本地 H5 交互回归:所有接口均由 fixture 拦截,不写真实业务库。 const origin = process.env.CONTRACT_TEST_ORIGIN || 'http://localhost:3006' const artifacts = '/tmp/shanxia-mobile-contract-print' await mkdir(artifacts, { recursive: true }) const ios = process.env.CONTRACT_TEST_IOS === '1' const browser = ios ? await webkit.launch({ headless: true }) : await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ ...(ios ? devices['iPhone 13'] : {}), ...(process.env.CONTRACT_TEST_IPHONE_UA ? { userAgent: devices['iPhone 13'].userAgent } : {}), ...(process.env.CONTRACT_TEST_WECHAT_UA ? { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 27_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.65 NetType/WIFI Language/zh_CN' } : {}), viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }) if (process.env.CONTRACT_TEST_BLOCK_SRCDOC === '1') { await context.addInitScript(() => { const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc') Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { ...descriptor, set(value) { if (this.id.startsWith('tinymce-')) return // 模拟WebView未完成srcdoc加载 descriptor.set.call(this, value) } }) }) } 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() }) let releaseEditorModel let modelRequested let slowEditor = ['1', 'timeout'].includes(process.env.CONTRACT_TEST_SLOW_EDITOR) const modelStarted = new Promise((resolve) => { modelRequested = resolve }) if (slowEditor) { let heldModel = false await context.route('**/tinymce/models/dom/model.min.js', async (route) => { if (heldModel) { await route.continue() return } heldModel = true modelRequested() await new Promise((resolve) => { releaseEditorModel = resolve }) await route.continue() }) } let retryEditor = ['1', 'core'].includes(process.env.CONTRACT_TEST_RETRY_EDITOR) if (retryEditor) { let firstModel = true await context.route( process.env.CONTRACT_TEST_RETRY_EDITOR === 'core' ? '**/tinymce/tinymce.min.js' : '**/tinymce/models/dom/model.min.js', async (route) => { if (firstModel) { firstModel = false await route.fulfill({ status: 503, contentType: 'text/plain', body: 'fixture transient failure' }) } else await route.continue() } ) } const page = await context.newPage() page.setDefaultTimeout(15000) const pageErrors = [] const calls = [] page.on('pageerror', (error) => { pageErrors.push(error.message) console.error('Browser error:', error.stack) }) page.on('console', (message) => { if (message.type() === 'error') console.error('Console:', message.text()) }) 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: '工业甲醇', productName: '工业甲醇', price: 12000, subtotal: 120000, taxAmount: 13805.31, taxRate: 13, 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 savedTemplate = '

移动打印验证 {{contractNo}}

需方:{{partyB}}

可编辑的合同条款

' let failUpload = false 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() {}, navigateTo(options) { window.__documentNavigation = options.url options.success?.({}) }, 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/crm/contract/getTemplate/contract-1') { data = savedTemplate } else if (path === '/web/crm/contract/saveTemplate') { savedTemplate = body.templateContent data = true } else if (path === '/web/admin/attachment/download-url') { data = { downloadUrl: 'https://res.shanxiachem.com/ruoyi/contract-export/fixture.pdf?signature=fixture' } } 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') { if (failUpload) { code = 500 msg = '模拟上传失败' } 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 }) }) }) async function openPrint() { const direct = page.getByRole('button', { name: '合同打印', exact: true }) if (!(await direct.isVisible())) await page.getByRole('button', { name: '更多', exact: true }).click() await direct.click() await page.locator('.mp-contract-print').waitFor() if (retryEditor) { await page.getByRole('button', { name: '重新加载编辑器', exact: true }).click() retryEditor = false } if (slowEditor) { await modelStarted const pdf = page .locator('.mp-contract-print') .getByRole('button', { name: '生成 PDF', exact: true }) assert.equal(await pdf.isDisabled(), true, '初始化未完成时不能读取serializer或导出') assert.equal( await page .locator('.mp-contract-print') .getByRole('button', { name: '保存模板', exact: true }) .isDisabled(), true ) assert.equal( await page .locator('.mp-contract-print') .getByRole('button', { name: '关闭', exact: true }) .isEnabled(), true ) slowEditor = false if (process.env.CONTRACT_TEST_SLOW_EDITOR === 'timeout') { await page .getByRole('button', { name: '重新加载页面', exact: true }) .click({ timeout: 25000 }) await page.waitForFunction(() => window.tinymce?.activeEditor?.initialized) } releaseEditorModel() } await page.waitForFunction(() => window.tinymce?.activeEditor?.initialized) } async function clickPrintButton(name) { await page.locator('.mp-contract-print').getByRole('button', { name, exact: true }).click() } try { await page.goto(`${origin}/#/m/crm/contract/contract-1?mpTicket=print-fixture`) await page.getByRole('heading', { name: '合同详情', exact: true }).waitFor() await openPrint() assert.match( await page.evaluate(() => window.tinymce.activeEditor.getContent()), /HT-20261017-001/ ) for (const width of [375, 414]) { await page.setViewportSize({ width, height: 812 }) assert.equal( await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false ) await page.waitForFunction( (expected) => Math.round(document.querySelector('.mp-contract-print').getBoundingClientRect().width) === expected, width ) assert.equal(Math.round((await page.locator('.mp-contract-print').boundingBox()).width), width) const box = await page .locator('.mp-contract-print') .getByRole('button', { name: '生成 PDF', exact: true }) .boundingBox() assert.ok(box && box.x >= 0 && box.x + box.width <= width && box.y + box.height <= 812) } await page.evaluate(() => { const editor = window.tinymce.activeEditor editor.setContent(editor.getContent() + '

手机编辑保留内容

') editor.fire('change') }) await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/saveTemplate')), clickPrintButton('保存模板') ]) assert.match(savedTemplate, /手机编辑保留内容/) const save = calls.findLast((call) => call.path.endsWith('/saveTemplate')) assert.equal(save.body.id, 'contract-1') assert.equal('products' in save.body, false) await clickPrintButton('预览模式') await page .locator('.mp-contract-print') .getByRole('button', { name: '编辑模式', exact: true }) .waitFor() await clickPrintButton('编辑模式') await clickPrintButton('关闭') await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) await openPrint() assert.match( await page.evaluate(() => window.tinymce.activeEditor.getContent()), /手机编辑保留内容/ ) await clickPrintButton('关闭') await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) savedTemplate = '' await openPrint() await page.waitForFunction(() => window.tinymce.activeEditor.getContent().includes('工业甲醇')) assert.match(await page.evaluate(() => window.tinymce.activeEditor.getContent()), /产品名称/) assert.ok( await page.evaluate( () => window.tinymce.activeEditor.getBody().scrollWidth > window.tinymce.activeEditor.getWin().innerWidth ) ) await clickPrintButton('隐藏红章') await page.waitForFunction(() => !window.tinymce.activeEditor.getContent().includes('alt="印章"')) await clickPrintButton('显示红章') await page.waitForFunction(() => window.tinymce.activeEditor.getContent().includes('alt="印章"')) await page.screenshot({ path: `${artifacts}/editor-414.png` }) // 本阶段验证普通浏览器下载;之后单独切到微信宿主验证原生交付。 await page.evaluate(() => { Object.defineProperty(navigator, 'userAgent', { configurable: true, value: navigator.userAgent.replace(/MicroMessenger\/\S+|miniProgram/gi, '') }) }) for (const [label, extension] of [ ['生成 Word', '.docx'], ['生成 PDF', '.pdf'] ]) { const downloadPromise = page.waitForEvent('download', { timeout: 60000 }) await clickPrintButton(label) const download = await downloadPromise assert.ok(download.suggestedFilename().endsWith(extension)) await download.saveAs(`${artifacts}/contract${extension}`) const bytes = await readFile(`${artifacts}/contract${extension}`) assert.ok(bytes.length > 100) if (extension === '.pdf') assert.equal(bytes.subarray(0, 4).toString(), '%PDF') else assert.equal(bytes.subarray(0, 2).toString(), 'PK') } await page.evaluate(() => { // iPhone 同步环境标记缺失,但 JSSDK getEnv 能确认真实小程序宿主。 delete window.__wxjs_environment Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 MicroMessenger/8.0' }) window.wx.miniProgram.getEnv = (callback) => setTimeout(() => callback({ miniprogram: true }), 30) }) failUpload = true await clickPrintButton('生成 PDF') await page.getByText('模拟上传失败', { exact: true }).first().waitFor() await page .locator('.mp-contract-print') .getByRole('button', { name: '生成 PDF', exact: true }) .waitFor() assert.match(await page.evaluate(() => window.tinymce.activeEditor.getContent()), /工业甲醇/) assert.equal(await page.evaluate(() => window.__documentNavigation), undefined) failUpload = false await clickPrintButton('生成 PDF') await page.waitForFunction(() => window.__documentNavigation) assert.match( await page.evaluate(() => window.__documentNavigation), /pages\/common\/contract-document/ ) assert.ok(calls.some((call) => call.path === '/web/admin/attachment/upload')) assert.ok(calls.some((call) => call.path === '/web/admin/attachment/download-url')) await clickPrintButton('关闭') await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) baseContract.contractNo = 'YD/X-JT20261031001' await openPrint() await page.evaluate(() => { window.tinymce.activeEditor.fire('SetContent') const close = [...document.querySelectorAll('.mp-contract-print button')].find( (button) => button.textContent.trim() === '关闭' ) close.click() }) await page.locator('.mp-contract-print').waitFor({ state: 'hidden' }) // 等待Logo回调的500ms窗口,验证关闭后没有访问已销毁的编辑器。 await page.waitForTimeout(650) assert.deepEqual(pageErrors, []) console.log( 'PASS mobile contract print: 375/414 layout, template save, preview, DOCX/PDF downloads, mini-program file bridge; fixture only' ) } finally { await page.screenshot({ path: `${artifacts}/last-state.png` }).catch(() => {}) await browser.close() }