feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。

This commit is contained in:
wuzilong committed 2026-10-31 22:34:11 +08:00
1 parent 0cd120ef5e
commit 13630045df
16 files changed
+3666 -4

No files matched your search

@@ -0,0 +1,151 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import ts from 'typescript'
// 执行真实交付函数,仅替换浏览器宿主与 HTTP 边界。
const source = readFileSync(
new URL('../src/views/m/crm/contract/print/deliverContractFile.ts', import.meta.url),
'utf8'
)
const code = ts.transpileModule(
source.replace(/^import .*$/gm, '').replace('export async function', 'async function'),
{ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }
).outputText
function harness({
wechat = true,
marked = false,
env = true,
navigation = 'success',
delayedSdk = false,
pendingBridge = false
} = {}) {
const calls = { saved: 0, uploaded: 0, navigated: [] as string[] }
const events = new EventTarget()
const script = new EventTarget()
const mini = {
getEnv(cb: (value: { miniprogram: boolean }) => void) {
if (pendingBridge) {
// JSSDK 1.6.0:桥接就绪后才读取宿主注入的环境标记。
events.addEventListener('WeixinJSBridgeReady', () => cb({ miniprogram: marked }), {
once: true
})
return
}
setTimeout(() => cb({ miniprogram: env }), 1)
},
navigateTo(options: any) {
calls.navigated.push(options.url)
if (navigation === 'success') options.success()
if (navigation === 'fail') options.fail({ errMsg: 'page not found' })
}
}
const window: any = { wx: delayedSdk ? undefined : { miniProgram: mini } }
const context = vm.createContext({
window,
navigator: { userAgent: wechat ? 'iPhone MicroMessenger/8.0' : 'Safari' },
document: {
querySelector: () => (delayedSdk ? script : null),
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events)
},
setTimeout: (fn: () => void, ms: number) => setTimeout(fn, Math.min(ms, 30)),
clearTimeout,
File,
encodeURIComponent,
FileSaver: {
saveAs() {
calls.saved++
}
},
isInMiniProgramWebView: () => marked,
CRM_UPLOAD_BIZ_TYPE: { CONTRACT_ATTACHMENT: 'contract' },
buildAttachmentUploadFormData: (file: File) => file,
fetchUploadAttachment: async () => {
calls.uploaded++
return { data: { fileUrl: 'contracts/a.pdf' } }
},
fetchAttachmentDownloadUrl: async () => ({
data: { downloadUrl: 'https://res.example.com/a.pdf?signature=x' }
})
})
vm.runInContext(code + '\nglobalThis.deliver = deliverContractFile', context)
return {
calls,
deliver: (isActive?: () => boolean) =>
context.deliver(new Blob(['%PDF-1.3']), '测试合同.pdf', isActive),
ready() {
window.wx = { miniProgram: mini }
script.dispatchEvent(new Event('load'))
},
bridgeReady() {
marked = true
events.dispatchEvent(new Event('WeixinJSBridgeReady'))
}
}
}
test('iPhone WeChat without synchronous markers waits for getEnv and opens native document page', async () => {
const h = harness()
assert.equal(await h.deliver(), 'mini-program-requested')
assert.equal(h.calls.saved, 0)
assert.equal(h.calls.uploaded, 1)
assert.match(h.calls.navigated[0], /^\/pages\/common\/contract-document\?url=/)
})
test('ordinary browser still requests a browser download despite a JSSDK shell', async () => {
const h = harness({ wechat: false })
assert.equal(await h.deliver(), 'browser-download')
assert.equal(h.calls.saved, 1)
assert.equal(h.calls.uploaded, 0)
})
test('waits for the real JSSDK ordering: bridge ready then injected environment marker', async () => {
const h = harness({ pendingBridge: true })
const result = h.deliver()
assert.equal(h.calls.saved, 0)
assert.equal(h.calls.uploaded, 0)
h.bridgeReady()
assert.equal(await result, 'mini-program-requested')
assert.equal(h.calls.uploaded, 1)
assert.equal(h.calls.navigated.length, 1)
})
test('waits for an already loading JSSDK before delivering', async () => {
const h = harness({ delayedSdk: true })
const result = h.deliver()
h.ready()
assert.equal(await result, 'mini-program-requested')
assert.equal(h.calls.saved, 0)
})
test('unconfirmed WeChat environment does not silently fall back to browser downloads', async () => {
const h = harness({ env: false })
await assert.rejects(h.deliver(), /小程序/)
assert.equal(h.calls.saved, 0)
assert.equal(h.calls.uploaded, 0)
})
test('missing JSSDK times out without downloading or uploading', async () => {
const h = harness({ delayedSdk: true })
await assert.rejects(h.deliver(), /微信文档接口未就绪/)
h.ready()
assert.equal(h.calls.saved, 0)
assert.equal(h.calls.uploaded, 0)
})
test('closing while environment is pending prevents file upload and navigation', async () => {
const h = harness()
let active = true
const result = h.deliver(() => active)
active = false
assert.equal(await result, undefined)
assert.equal(h.calls.saved, 0)
assert.equal(h.calls.uploaded, 0)
assert.equal(h.calls.navigated.length, 0)
})
for (const navigation of ['fail', 'silent']) {
test(
`native navigation ${navigation} rejects instead of reporting success`,
{ timeout: 200 },
async () => {
const h = harness({ marked: true, navigation })
await assert.rejects(h.deliver(), /文档/)
assert.equal(h.calls.saved, 0)
}
)
}
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
const moduleUrl = new URL('../src/views/m/crm/contract/print/sealPointerDrag.ts', import.meta.url)
test('touch pointer drags a seal and remove detaches listeners', async () => {
assert.ok(existsSync(moduleUrl), 'pointer seal adapter exists')
const { setupSealPointerDrag } = await import(moduleUrl.href)
const handlers = new Map()
const removed = []
const doc = {
addEventListener: (key, fn) => handlers.set(key, fn),
removeEventListener: (key) => {
removed.push(key)
handlers.delete(key)
}
}
const body = { ownerDocument: doc, contains: () => true }
let changes = 0
let cleanup
const editor = {
getBody: () => body,
mode: { get: () => 'design' },
fire: () => changes++,
on: (name, fn) => (cleanup = fn)
}
setupSealPointerDrag(editor)
const img = {
tagName: 'IMG',
hasAttribute: () => true,
style: { left: '30px', top: '40px' },
offsetLeft: 30,
offsetTop: 40,
setPointerCapture() {},
releasePointerCapture() {}
}
const event = (x, y, id = 1) => ({
target: img,
clientX: x,
clientY: y,
pointerId: id,
pointerType: 'touch',
button: 0,
preventDefault() {}
})
handlers.get('pointerdown')(event(50, 60))
handlers.get('pointermove')(event(80, 100, 2))
assert.equal(img.style.left, '30px')
handlers.get('pointermove')(event(80, 100))
assert.equal(img.style.left, '60px')
assert.equal(img.style.top, '80px')
handlers.get('pointerup')(event(80, 100))
assert.equal(changes, 1)
cleanup()
assert.equal(handlers.size, 0)
assert.equal(removed.length, 4)
})
@@ -0,0 +1,536 @@
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()
}