build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
164 lines
9.0 KiB
JavaScript
164 lines
9.0 KiB
JavaScript
const { test } = require("node:test")
|
|
const assert = require("node:assert/strict")
|
|
const fs = require("node:fs")
|
|
const path = require("node:path")
|
|
const ts = require("typescript")
|
|
const vue = require("vue")
|
|
const { parse, compileScript } = require("@vue/compiler-sfc")
|
|
const root = path.join(__dirname, "../src")
|
|
const flush = () => new Promise(setImmediate)
|
|
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
|
|
|
function load(source, mocks, globals = {}) {
|
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 } }).outputText
|
|
const module = { exports: {} }
|
|
const names = Object.keys(globals)
|
|
new Function("module", "exports", "require", ...names, compiled)(module, module.exports, key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
|
return module.exports
|
|
}
|
|
|
|
function component(query) {
|
|
let unmount
|
|
let now = 0
|
|
let timerId = 0
|
|
const timers = new Map()
|
|
const events = []
|
|
const requests = []
|
|
let exposed
|
|
const source = compileScript(parse(fs.readFileSync(path.join(root, "components/AsyncTaskProgress.vue"), "utf8")).descriptor, { id: "test" }).content
|
|
const mod = load(source, {
|
|
vue: { ...vue, onBeforeUnmount: fn => { unmount = fn } },
|
|
"/@/api/asyncTask": { getAsyncTaskProgress: async (url, params) => { requests.push({ url, params }); return query(requests.length) } },
|
|
"/@/utils/exportDownload": { downloadExportFile() {} },
|
|
"/@/hooks/message": { useMessage: () => ({ error() {} }) },
|
|
}, {
|
|
setTimeout: fn => { timers.set(++timerId, fn); return timerId },
|
|
clearTimeout: id => timers.delete(id),
|
|
Date: { now: () => now },
|
|
})
|
|
const state = mod.default.setup({ config: { progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:xf", pollTimeoutMs: 1000 } }, {
|
|
expose: value => { exposed = value }, emit: (...args) => events.push(args),
|
|
})
|
|
return { state, exposed, events, requests, timers, unmount: () => unmount(), setNow: value => { now = value },
|
|
async tick() { const entry = timers.entries().next().value; assert.ok(entry, "下一次轮询已安排"); timers.delete(entry[0]); entry[1](); await flush() } }
|
|
}
|
|
const initial = { taskId: "a".repeat(32), redisKey: "wallet:export:xf", taskName: "消费记录", status: "PENDING", total: 1, completed: 0, percentage: 0, message: "等待执行", finished: false, success: false }
|
|
const success = { ...initial, status: "SUCCESS", completed: 1, percentage: 100, finished: true, success: true, resultFileUrl: "/admin/sys-file/oss/file?fileName=test.xlsx", resultFileName: "消费记录.xlsx" }
|
|
|
|
test("提交互斥且冻结嵌套筛选,列表分页不限制导出", async () => {
|
|
const submitted = deferred()
|
|
const calls = []
|
|
const ui = []
|
|
const downloads = []
|
|
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
|
vue,
|
|
"/@/components/AsyncTaskProgress.vue": {},
|
|
"/@/hooks/message": { useMessage: () => ({ error: value => ui.push(value) }) },
|
|
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
|
})
|
|
const task = mod.useBatchExport({ redisKey: initial.redisKey, progressUrl: "/wallet/async-task/progress", taskName: "导出", fileName: "回退.xlsx", submitTask: async query => { calls.push(query); return submitted.promise } })
|
|
task.exportProgressRef.value = { startSubmitting: () => ui.push("submit"), start: () => ui.push("start"), show: () => ui.push("show") }
|
|
const query = vue.reactive({ current: 8, size: 10, userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
|
|
const pending = task.submitExport(query)
|
|
await task.submitExport(query)
|
|
query.userIds.push("2"); query.range.from = "2026-02-01"
|
|
assert.equal(calls.length, 1)
|
|
assert.deepEqual(calls[0], { userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
|
|
assert.deepEqual(ui, ["submit", "show"])
|
|
submitted.resolve({ data: initial }); await pending
|
|
assert.equal(task.exportSubmitting.value, true)
|
|
task.handleExportSuccess(success)
|
|
assert.equal(task.exportSubmitting.value, false)
|
|
assert.deepEqual(downloads[0], [success.resultFileUrl, success.resultFileName])
|
|
})
|
|
|
|
test("提交失败恢复按钮状态并展示受控错误", async () => {
|
|
const errors = []
|
|
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
|
vue, "/@/components/AsyncTaskProgress.vue": {},
|
|
"/@/hooks/message": { useMessage: () => ({ error: message => errors.push(message) }) },
|
|
"/@/utils/exportDownload": {},
|
|
})
|
|
const task = mod.useBatchExport({ submitTask: async () => { throw { msg: "任务繁忙" } } })
|
|
task.exportProgressRef.value = { startSubmitting() {}, submissionFailed: message => errors.push(message) }
|
|
await task.submitExport({})
|
|
assert.equal(task.exportSubmitting.value, false)
|
|
assert.deepEqual(errors, ["任务繁忙", "任务繁忙"])
|
|
})
|
|
|
|
test("慢查询不重叠,成功只通知一次且停止轮询", async () => {
|
|
const pending = deferred()
|
|
const task = component(async count => count === 1 ? pending.promise : { data: success })
|
|
task.exposed.startSubmitting("导出")
|
|
assert.equal(task.state.canClose.value, false)
|
|
task.exposed.start(initial)
|
|
await flush()
|
|
assert.equal(task.requests.length, 1)
|
|
assert.equal(task.timers.size, 0)
|
|
pending.resolve({ data: { ...initial, status: "RUNNING", total: 10, completed: 1, percentage: 10 } }); await flush()
|
|
assert.equal(task.timers.size, 1)
|
|
await task.tick()
|
|
assert.equal(task.events.length, 1)
|
|
assert.equal(task.events[0][0], "success")
|
|
assert.equal(task.timers.size, 0)
|
|
assert.equal(task.state.canClose.value, true)
|
|
})
|
|
|
|
test("连续五次查询失败暂停,重新查询原任务后仍可下载", async () => {
|
|
const task = component(async count => { if (count <= 5) throw { msg: "网络中断" }; return { data: success } })
|
|
task.exposed.start(initial); await flush()
|
|
for (let i = 0; i < 4; i++) await task.tick()
|
|
assert.equal(task.requests.length, 5)
|
|
assert.equal(task.state.state.polling, false)
|
|
assert.equal(task.state.state.queryError, "网络中断")
|
|
task.state.state.visible = false; task.exposed.show()
|
|
assert.equal(task.state.state.visible, true)
|
|
task.state.retry(); await flush()
|
|
assert.equal(task.requests.length, 6)
|
|
assert.ok(task.requests.every(item => item.params.taskId === initial.taskId))
|
|
assert.equal(task.events[0][0], "success")
|
|
})
|
|
|
|
test("卸载或新任务替换后忽略旧请求响应", async () => {
|
|
const pending = deferred()
|
|
const task = component(async () => pending.promise)
|
|
task.exposed.start(initial); await flush(); task.unmount()
|
|
pending.resolve({ data: success }); await flush()
|
|
assert.equal(task.timers.size, 0)
|
|
assert.equal(task.events.length, 0)
|
|
const old = deferred()
|
|
const replacement = component(async count => count === 1 ? old.promise : { data: { ...initial, taskId: "b".repeat(32) } })
|
|
replacement.exposed.start(initial)
|
|
replacement.exposed.start({ ...initial, taskId: "b".repeat(32) }); await flush()
|
|
old.resolve({ data: success }); await flush()
|
|
assert.equal(replacement.state.state.progress.taskId, "b".repeat(32))
|
|
assert.equal(replacement.events.length, 0)
|
|
})
|
|
|
|
test("轮询超时只暂停跟踪,后台失败才发出 failure", async () => {
|
|
const task = component(async () => ({ data: initial }))
|
|
task.exposed.start(initial); await flush(); task.setNow(1001)
|
|
await task.tick()
|
|
assert.equal(task.requests.length, 1)
|
|
assert.match(task.state.state.queryError, /后台任务可能仍在运行/)
|
|
assert.equal(task.events.length, 0)
|
|
const failed = component(async () => ({ data: { ...initial, finished: true, success: false, status: "FAILED", message: "导出失败" } }))
|
|
failed.exposed.start(initial); await flush()
|
|
assert.equal(failed.events[0][0], "failure")
|
|
assert.equal(failed.state.canDownload.value, false)
|
|
})
|
|
|
|
test("大文件下载使用原生附件地址并适配单体路径,不创建 Blob", () => {
|
|
const links = []
|
|
const source = fs.readFileSync(path.join(root, "utils/exportDownload.ts"), "utf8").replaceAll("import.meta.env", '({ VITE_API_URL: "/api" })')
|
|
const mod = load(source, { "/@/utils/other": { default: { adaptationUrl: path => path.replace("/hard/", "/admin/") } } }, {
|
|
document: { createElement: () => { const link = { style: {}, click() {}, remove() {} }; links.push(link); return link }, body: { appendChild() {} } },
|
|
Blob: class { constructor() { throw new Error("不能将整个大文件读入 Blob") } },
|
|
})
|
|
mod.downloadExportFile("/hard/file?id=large", "充值/扣款.xlsx")
|
|
assert.equal(links[0].href, "/api/admin/file?id=large")
|
|
assert.equal(links[0].download, "充值_扣款.xlsx")
|
|
mod.downloadExportFile("https://files.example.com/test", "test.xlsx")
|
|
assert.equal(links[1].href, "https://files.example.com/test")
|
|
})
|