Files
crawler-plugin/frontend-vue/tests/polling-backoff-recovery.test.ts
T
huangzd1997 c81ebb7053 fix(前端测试): 修复 npm test 卡死——定时器泄漏 + 去套娃 + 强制退出/超时
- 根因: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,无残留进程
2026-09-13 10:51:19 +08:00

262 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})