feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。
This commit is contained in:
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()
|
||||
}
|
||||
Reference in new issue
Block a user