Files
shanxia2.0/web-admin/scripts/test-mobile-contract-print.mjs
T

537 lines
18 KiB
JavaScript

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 =
'<div style="width:700px"><h2>移动打印验证 {{contractNo}}</h2><p>需方:{{partyB}}</p><p>可编辑的合同条款</p></div>'
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() + '<p>手机编辑保留内容</p>')
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()
}