c81ebb7053
- 根因:polling-backoff-recovery 测试断言失败后跳过 loop.dispose(),残留自续期定时器导致 node --test 子进程永不退出 - dead-code-cleanup:移除套娃用例 test_full_vitest_green(再跑一遍全量套件,放大问题),execSync 加 300s 超时;其余静态断言保留 - package.json:node --test --test-force-exit --test-timeout=180000 - CI workflow 前端测试步骤改为 npm test(与 package.json 单一来源) - 修正 3 处过时期望值(轮询退避/品牌 files+taskType/巡店 delete_conditions) - 验证:本地 666 用例全过,19-24s,无残留进程
262 lines
8.6 KiB
TypeScript
262 lines
8.6 KiB
TypeScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts'
|
||
import {
|
||
TASK_POLL_VISIBLE_INTERVAL_MS,
|
||
getTaskPollBackoffMs,
|
||
} from '../src/shared/task-progress-config.ts'
|
||
|
||
const tick = (ms = 0) => new Promise<void>((r) => globalThis.setTimeout(r, ms))
|
||
|
||
interface SinkEntry {
|
||
fn: () => void
|
||
ms: number
|
||
id: unknown
|
||
}
|
||
|
||
function setupWindow(sink?: SinkEntry[]) {
|
||
const storage = new Map<string, string>()
|
||
;(globalThis as Record<string, unknown>).window = {
|
||
localStorage: {
|
||
getItem: (k: string) => storage.get(k) ?? null,
|
||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||
removeItem: (k: string) => { storage.delete(k) },
|
||
},
|
||
setTimeout: (fn: () => void, ms: number) => {
|
||
const id = globalThis.setTimeout(fn, ms)
|
||
sink?.push({ fn, ms, id })
|
||
return id
|
||
},
|
||
clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number),
|
||
setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms),
|
||
clearInterval: (id: unknown) => globalThis.clearInterval(id as number),
|
||
}
|
||
}
|
||
|
||
interface FakeDoc {
|
||
setVisibility: (s: string) => void
|
||
dispatch: () => void
|
||
}
|
||
|
||
function installFakeDocument(): FakeDoc {
|
||
let state = 'visible'
|
||
const listeners = new Map<string, Array<(e?: unknown) => void>>()
|
||
;(globalThis as Record<string, unknown>).document = {
|
||
get visibilityState() { return state },
|
||
get hidden() { return state !== 'visible' },
|
||
addEventListener: (type: string, fn: (e?: unknown) => void) => {
|
||
const arr = listeners.get(type) ?? []
|
||
arr.push(fn)
|
||
listeners.set(type, arr)
|
||
},
|
||
removeEventListener: (type: string, fn: (e?: unknown) => void) => {
|
||
const arr = listeners.get(type) ?? []
|
||
listeners.set(type, arr.filter((f) => f !== fn))
|
||
},
|
||
}
|
||
return {
|
||
setVisibility: (s) => { state = s },
|
||
dispatch: () => { for (const fn of listeners.get('visibilitychange') ?? []) fn() },
|
||
}
|
||
}
|
||
|
||
let scopeCounter = 0
|
||
|
||
type Item = { taskId?: number; status?: string }
|
||
|
||
interface SeqHarness {
|
||
loop: ReturnType<typeof useTaskProgressLoop<Item>>
|
||
attempts: number[]
|
||
terminalCalls: Array<{ taskId: number; status: string }>
|
||
}
|
||
|
||
/** 顺序驱动的轮询循环:fetchImpl 依状态返回成功/失败;自动首轮会被吸收 */
|
||
function makeSequenceLoop(
|
||
fetchImpl: () => Promise<{ items: Item[] }>,
|
||
sink?: SinkEntry[],
|
||
): SeqHarness {
|
||
setupWindow(sink)
|
||
const attempts: number[] = []
|
||
const terminalCalls: Array<{ taskId: number; status: string }> = []
|
||
const loop = useTaskProgressLoop<Item>({
|
||
scope: `recover-${scopeCounter++}`,
|
||
fetchProgress: async () => fetchImpl(),
|
||
extractTaskId: (d) => d?.taskId,
|
||
extractStatus: (d) => d?.status,
|
||
getIntervalMs: () => TASK_POLL_VISIBLE_INTERVAL_MS,
|
||
onTerminal: (taskId, _d, status) => { terminalCalls.push({ taskId, status }) },
|
||
onError: (_e, attempt) => { attempts.push(attempt ?? -1) },
|
||
})
|
||
loop.reset([1])
|
||
return { loop, attempts, terminalCalls }
|
||
}
|
||
|
||
test('test_recovery_after_failure', async () => {
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('boom')
|
||
return { items: [{ taskId: 1, status: 'SUCCESS' }] }
|
||
})
|
||
await tick(10)
|
||
assert.equal(h.attempts.length, 1, '首轮自动轮询失败计入 attempt 0')
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
assert.equal(h.terminalCalls.length, 1, '失败后成功恢复触发终态')
|
||
assert.equal(h.terminalCalls[0].status, 'SUCCESS')
|
||
assert.deepEqual(h.loop.taskIds.value, [], '终态任务移除')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_backoff_reset_after_success', async () => {
|
||
let mode: 'fail' | 'ok' = 'fail'
|
||
const h = makeSequenceLoop(async () => {
|
||
if (mode === 'fail') throw new Error('x')
|
||
return { items: [] }
|
||
})
|
||
await tick(10)
|
||
await h.loop.refreshOnce()
|
||
mode = 'ok'
|
||
await h.loop.refreshOnce()
|
||
mode = 'fail'
|
||
await h.loop.refreshOnce()
|
||
assert.deepEqual(h.attempts, [0, 1, 0], '成功清零后下一次失败从 attempt 0 起')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_interval_returns_to_base', async () => {
|
||
const sink: SinkEntry[] = []
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [] }
|
||
}, sink)
|
||
await tick(10)
|
||
assert.equal(h.attempts.length, 1, '首轮失败')
|
||
assert.equal(sink.at(-1)?.ms, getTaskPollBackoffMs(1), '失败后按退避间隔续轮(后端故障时不再高频撞库)')
|
||
fail = false
|
||
// 手动触发下一轮自动轮询(模拟定时器到点;先清掉真实定时器避免僵尸 timer)
|
||
const last = sink.at(-1) as SinkEntry
|
||
globalThis.clearTimeout(last.id)
|
||
last.fn()
|
||
await tick(10)
|
||
assert.equal(sink.at(-1)?.ms, TASK_POLL_VISIBLE_INTERVAL_MS, '成功后回到正常间隔')
|
||
assert.equal(sink.some((e) => e.ms === getTaskPollBackoffMs(0)), false, '自动轮询阶段无 in-flight 重试定时器残留')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_recovery_cycle_repeat', async () => {
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [] }
|
||
})
|
||
await tick(10)
|
||
// 失败-成功 × 2,再失败
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
fail = true
|
||
await h.loop.refreshOnce()
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
fail = true
|
||
await h.loop.refreshOnce()
|
||
assert.deepEqual(h.attempts, [0, 0, 0], '多次失败-成功循环每次从 attempt 0 重新起')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_recovery_partial_items', async () => {
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [{ taskId: 1, status: 'RUNNING' }] }
|
||
})
|
||
await tick(10)
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
fail = true
|
||
await h.loop.refreshOnce()
|
||
assert.equal(h.loop.taskStatuses.value[1], 'RUNNING', '部分成功落地状态')
|
||
assert.deepEqual(h.attempts, [0, 0], '有 items 的响应视为成功,计数归零')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_recovery_with_foreground', async () => {
|
||
const doc = installFakeDocument()
|
||
setupWindow()
|
||
let fail = true
|
||
const attempts: number[] = []
|
||
const loop = useTaskProgressLoop<Item>({
|
||
scope: `recover-fore-${scopeCounter++}`,
|
||
fetchProgress: async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [{ taskId: 1, status: 'RUNNING' }] }
|
||
},
|
||
extractTaskId: (d) => d?.taskId,
|
||
extractStatus: (d) => d?.status,
|
||
getIntervalMs: () => TASK_POLL_VISIBLE_INTERVAL_MS,
|
||
onError: (_e, attempt) => { attempts.push(attempt ?? -1) },
|
||
})
|
||
loop.reset([1])
|
||
await tick(10)
|
||
assert.equal(attempts.length, 1, '可见状态下首轮失败')
|
||
doc.setVisibility('hidden')
|
||
fail = false
|
||
doc.setVisibility('visible')
|
||
doc.dispatch()
|
||
await tick(20)
|
||
assert.equal(loop.taskStatuses.value[1], 'RUNNING', '切回前台立即刷新并恢复成功')
|
||
fail = true
|
||
await loop.refreshOnce()
|
||
assert.deepEqual(attempts, [0, 0], '前台恢复成功后计数归零')
|
||
loop.dispose()
|
||
delete (globalThis as Record<string, unknown>).document
|
||
})
|
||
|
||
test('test_recovery_after_many_failures', async () => {
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [{ taskId: 1, status: 'SUCCESS' }] }
|
||
})
|
||
await tick(10)
|
||
for (let i = 0; i < 7; i++) {
|
||
await h.loop.refreshOnce()
|
||
}
|
||
assert.equal(h.attempts.length, 8, '连续失败累计 attempt')
|
||
assert.deepEqual(h.attempts, [0, 1, 2, 3, 4, 5, 6, 7])
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
assert.equal(h.terminalCalls.length, 1, '长失败链后仍可恢复')
|
||
assert.equal(h.terminalCalls[0].status, 'SUCCESS')
|
||
// SUCCESS 后任务已移除;重新 add 再制造失败,验证计数重新从 0 起
|
||
fail = true
|
||
h.loop.add(1)
|
||
await tick(10)
|
||
assert.equal(h.attempts[8], 0, '恢复后重新从 0 起')
|
||
h.loop.dispose()
|
||
})
|
||
|
||
test('test_recovery_timing_assert', async () => {
|
||
// attempt → 退避延迟映射:500,1000,2000,4000,8000,封顶 10000
|
||
const expected = [500, 1000, 2000, 4000, 8000, 10000, 10000]
|
||
for (let i = 0; i < expected.length; i++) {
|
||
assert.equal(getTaskPollBackoffMs(i), expected[i], `attempt ${i} 退避延迟`)
|
||
}
|
||
let fail = true
|
||
const h = makeSequenceLoop(async () => {
|
||
if (fail) throw new Error('x')
|
||
return { items: [] }
|
||
})
|
||
await tick(10)
|
||
await h.loop.refreshOnce()
|
||
fail = false
|
||
await h.loop.refreshOnce()
|
||
fail = true
|
||
await h.loop.refreshOnce()
|
||
assert.deepEqual(h.attempts, [0, 1, 0], '观察到的 attempt 序列')
|
||
assert.equal(getTaskPollBackoffMs(h.attempts[0]), 500, '失败从 500ms 起')
|
||
assert.equal(getTaskPollBackoffMs(h.attempts[1]), 1000, '二次失败 1000ms')
|
||
assert.equal(getTaskPollBackoffMs(h.attempts[2]), 500, '恢复后重新从 500ms 起')
|
||
h.loop.dispose()
|
||
})
|