537 lines
18 KiB
JavaScript
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()
|
|
}
|