248 lines
11 KiB
JavaScript
248 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { test } from 'node:test'
|
|
import { parseHTML } from 'linkedom'
|
|
import { readFile } from 'node:fs/promises'
|
|
import * as Vue from 'vue'
|
|
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
|
|
|
const utilityUrl = new URL('../src/utils/shipmentLabelPrint.js', import.meta.url)
|
|
|
|
async function printer() {
|
|
let module
|
|
try { module = await import(utilityUrl.href) } catch (error) {
|
|
assert.fail(`缺少可执行的标签打印工具:${error.message}`)
|
|
}
|
|
return module.printShipmentLabels
|
|
}
|
|
|
|
function popup() {
|
|
const { document } = parseHTML('<html><head></head><body></body></html>')
|
|
const events = new EventTarget()
|
|
const result = {
|
|
document, closed: false, printed: 0, focused: false,
|
|
close() { this.closed = true },
|
|
focus() { this.focused = true },
|
|
print() { this.printed += 1 },
|
|
addEventListener: events.addEventListener.bind(events),
|
|
dispatchEvent: events.dispatchEvent.bind(events)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const label = {
|
|
shipmentNo: 'SH-原单号', shipmentStatus: 'CONFIRMED', palletCode: '原托盘-01',
|
|
customerName: '<script>globalThis.compromised=true</script>', siteName: '现场 & A',
|
|
strainName: '<img src=x onerror=alert(1)>', batchNo: '<svg/onload=alert(2)>',
|
|
productionDate: '2026-09-01', expiryDate: null, packageQty: 37,
|
|
qrCodeDataUrl: 'data:image/png;base64,aGVsbG8='
|
|
}
|
|
|
|
async function loadImages(win) {
|
|
// 模拟浏览器图片加载事件;标签节点和内容解析使用真实 DOM。
|
|
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
|
|
for (const img of win.document.querySelectorAll('img')) {
|
|
Object.defineProperty(img, 'naturalWidth', { value: 100 })
|
|
img.dispatchEvent(new win.document.defaultView.Event('load'))
|
|
}
|
|
}
|
|
|
|
test('多托标签保留原始内容且不执行 HTML,二维码加载后才打印并逐托分页', async () => {
|
|
const print = await printer()
|
|
const win = popup()
|
|
const errors = []
|
|
const pending = print({ openWindow: () => win, loadLabels: async () => [label, { ...label, palletCode: '原托盘-02' }], notifyError: e => errors.push(e) })
|
|
await Promise.resolve()
|
|
assert.equal(win.printed, 0)
|
|
await loadImages(win)
|
|
assert.equal(await pending, true)
|
|
assert.equal(win.printed, 1)
|
|
assert.equal(win.document.querySelectorAll('script, svg').length, 0)
|
|
assert.equal(win.document.querySelectorAll('img').length, 2)
|
|
assert.equal(win.document.querySelectorAll('[onerror], [onload]').length, 0)
|
|
assert.ok(win.document.body.textContent.includes(label.customerName))
|
|
assert.ok(win.document.body.textContent.includes(label.strainName))
|
|
assert.ok(win.document.body.textContent.includes(label.batchNo))
|
|
for (const text of ['原托盘-01', '原托盘-02', 'SH-原单号', '现场 & A', '2026-09-01', '未登记', '37']) {
|
|
assert.ok(win.document.body.textContent.includes(text), text)
|
|
}
|
|
const pages = [...win.document.querySelectorAll('section')]
|
|
assert.equal(pages.length, 2)
|
|
assert.equal(pages[0].style.breakAfter, 'page')
|
|
assert.equal(pages[1].style.breakAfter, 'auto')
|
|
assert.equal(pages[0].style.breakInside, 'avoid')
|
|
assert.equal(win.document.querySelector('img').src, label.qrCodeDataUrl)
|
|
assert.deepEqual(errors, [])
|
|
win.dispatchEvent(new Event('afterprint'))
|
|
assert.equal(win.closed, true)
|
|
})
|
|
|
|
test('同步打开窗口以保留用户手势,弹窗被拦截时提示且不请求接口', async () => {
|
|
const print = await printer()
|
|
const calls = []
|
|
const errors = []
|
|
const pending = print({ openWindow: () => { calls.push('open'); return null }, loadLabels: async () => { calls.push('load'); return [label] }, notifyError: e => errors.push(e) })
|
|
assert.deepEqual(calls, ['open'])
|
|
assert.equal(await pending, false)
|
|
assert.match(errors[0], /弹窗|弹出/)
|
|
})
|
|
|
|
test('接口失败和空数据会关闭空窗口并提示,绝不打印', async () => {
|
|
const print = await printer()
|
|
for (const loadLabels of [async () => { throw new Error('network') }, async () => []]) {
|
|
const win = popup()
|
|
const errors = []
|
|
assert.equal(await print({ openWindow: () => win, loadLabels, notifyError: e => errors.push(e) }), false)
|
|
assert.equal(win.closed, true)
|
|
assert.equal(win.printed, 0)
|
|
assert.equal(errors.length, 1)
|
|
}
|
|
})
|
|
|
|
test('草稿显式标注,旧记录的两个日期显示未登记', async () => {
|
|
const print = await printer()
|
|
const win = popup()
|
|
const pending = print({ openWindow: () => win, loadLabels: async () => [{ ...label, shipmentStatus: 'DRAFT', productionDate: null }], notifyError: assert.fail })
|
|
await loadImages(win)
|
|
assert.equal(await pending, true)
|
|
assert.match(win.document.body.textContent, /草稿/)
|
|
assert.equal(win.document.body.textContent.match(/未登记/g).length, 2)
|
|
})
|
|
|
|
test('拒绝远程地址或非 PNG 二维码,避免不可信属性载荷', async () => {
|
|
const print = await printer()
|
|
for (const qrCodeDataUrl of ['https://attacker.example/track', 'javascript:alert(1)', 'data:image/svg+xml,<svg onload=alert(1)>']) {
|
|
const win = popup()
|
|
const errors = []
|
|
assert.equal(await print({ openWindow: () => win, loadLabels: async () => [{ ...label, qrCodeDataUrl }], notifyError: e => errors.push(e) }), false)
|
|
assert.equal(win.closed, true)
|
|
assert.equal(win.printed, 0)
|
|
assert.equal(errors.length, 1)
|
|
}
|
|
})
|
|
|
|
test('二维码加载失败会关闭窗口并提示', async () => {
|
|
const print = await printer()
|
|
const win = popup()
|
|
const errors = []
|
|
const pending = print({ openWindow: () => win, loadLabels: async () => [label], notifyError: e => errors.push(e) })
|
|
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
|
|
win.document.querySelector('img').dispatchEvent(new win.document.defaultView.Event('error'))
|
|
assert.equal(await pending, false)
|
|
assert.equal(win.closed, true)
|
|
assert.equal(win.printed, 0)
|
|
assert.match(errors[0], /二维码/)
|
|
})
|
|
|
|
test('异步过程中切换数据批次时丢弃标签并关闭窗口', async () => {
|
|
const print = await printer()
|
|
const win = popup()
|
|
const errors = []
|
|
let current = true
|
|
let resolve
|
|
const pending = print({ openWindow: () => win, loadLabels: () => new Promise(r => { resolve = r }), isCurrent: () => current, notifyError: e => errors.push(e) })
|
|
current = false
|
|
resolve([label])
|
|
assert.equal(await pending, false)
|
|
assert.equal(win.closed, true)
|
|
assert.equal(win.printed, 0)
|
|
assert.equal(win.document.querySelectorAll('img').length, 0)
|
|
assert.equal(errors.length, 1)
|
|
})
|
|
|
|
test('图片加载期间切换范围或关闭弹窗时不打印', async () => {
|
|
const print = await printer()
|
|
for (const change of ['scope', 'close']) {
|
|
const win = popup()
|
|
const errors = []
|
|
let current = true
|
|
const pending = print({ openWindow: () => win, loadLabels: async () => [label], isCurrent: () => current, notifyError: e => errors.push(e) })
|
|
await Promise.resolve()
|
|
if (change === 'scope') current = false
|
|
else win.close()
|
|
await loadImages(win)
|
|
assert.equal(await pending, false)
|
|
assert.equal(win.printed, 0)
|
|
assert.equal(win.closed, true)
|
|
assert.equal(errors.length, 1)
|
|
}
|
|
})
|
|
|
|
// 执行真实 SFC setup 和 API 封装,仅替换网络、窗口及 UI 提示边界。
|
|
function bindImports(source) {
|
|
return source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => {
|
|
const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`
|
|
return `const ${binding} = modules[${JSON.stringify(path)}];`
|
|
})
|
|
}
|
|
|
|
async function shipmentPage(load) {
|
|
const source = await readFile(new URL('../src/views/business/shipment/index.vue', import.meta.url), 'utf8')
|
|
const apiSource = await readFile(new URL('../src/api/business/shipment.js', import.meta.url), 'utf8')
|
|
const { descriptor } = parse(source)
|
|
const script = compileScript(descriptor, { id: 'shipment-label-test', genDefaultAs: 'component' })
|
|
const template = compileTemplate({ source: descriptor.template.content, filename: 'shipment/index.vue', id: 'shipment-label-test', compilerOptions: { bindingMetadata: script.bindings } })
|
|
assert.deepEqual(template.errors, [])
|
|
const exports = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
|
|
const requests = []
|
|
const api = new Function('modules', `${bindImports(apiSource).replaceAll('export function ', 'function ')}; return { ${exports.join(',')} };`)({
|
|
'@/utils/request': { default: config => {
|
|
requests.push(config)
|
|
return config.url.endsWith('/labels') ? load(config) : Promise.resolve({ rows: [], total: 0 })
|
|
} }
|
|
})
|
|
const win = popup()
|
|
const errors = []
|
|
const batch = { currentBatchId: '', scopeVersion: 0 }
|
|
const print = await printer()
|
|
const modules = {
|
|
'@/components/PalletScanInput': { default: {} },
|
|
'@/components/TestBatchAdoptionDialog': { default: {} },
|
|
'@/utils/palletScan': { createIdempotencyKey: () => 'not-used-for-printing' },
|
|
'@/store/modules/user': { default: () => ({ admin: false }) },
|
|
'@/store/modules/testBatch': { default: () => batch },
|
|
'@/utils/testBatchContext': { getStoredTestBatchId: () => batch.currentBatchId },
|
|
'@/utils/shipmentLabelPrint': { printShipmentLabels: options => print({ ...options, openWindow: () => win }) },
|
|
'@/api/business/shipment': api
|
|
}
|
|
const runtime = { ...Vue, getCurrentInstance: () => ({ proxy: { $auth: { hasPermiAnd: () => true }, $modal: { msgError: e => errors.push(e) } } }) }
|
|
const factory = new Function('modules', ...Object.keys(runtime), `${bindImports(script.content)}; return component;`)
|
|
const component = factory(modules, ...Object.values(runtime))
|
|
const scope = Vue.effectScope()
|
|
const page = scope.run(() => component.setup({}, { expose() {} }))
|
|
return { page, win, batch, requests, errors, dispose: () => scope.stop() }
|
|
}
|
|
|
|
test('页面真实处理器调用标签 GET 接口、阻止重复点击并在完成后恢复按钮', async () => {
|
|
let resolve
|
|
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
|
|
try {
|
|
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
|
|
assert.equal(context.page.labelLoadingId.value, 12)
|
|
await context.page.handlePrintLabels({ shipmentId: 13 })
|
|
const requests = context.requests.filter(request => request.url.endsWith('/labels'))
|
|
assert.deepEqual(requests, [{ url: '/business/shipment/12/labels', method: 'get' }])
|
|
resolve({ data: [label] })
|
|
await loadImages(context.win)
|
|
await pending
|
|
assert.equal(context.win.printed, 1)
|
|
assert.equal(context.page.labelLoadingId.value, null)
|
|
assert.deepEqual(context.errors, [])
|
|
} finally { context.dispose() }
|
|
})
|
|
|
|
test('批次切换后又切回原批次,页面仍丢弃旧请求并解除加载状态', async () => {
|
|
let resolve
|
|
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
|
|
try {
|
|
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
|
|
context.batch.scopeVersion += 2
|
|
resolve({ data: [label] })
|
|
await loadImages(context.win)
|
|
await pending
|
|
assert.equal(context.win.printed, 0)
|
|
assert.equal(context.win.closed, true)
|
|
assert.equal(context.page.labelLoadingId.value, null)
|
|
assert.equal(context.errors.length, 1)
|
|
} finally { context.dispose() }
|
|
})
|