Files
crawler-plugin/frontend-vue/tests/polling-backoff-recovery.test.ts
T
huangzd1997 9af7a5170a task-42: 退避恢复测试
失败→退避序列→成功→恢复正常间隔;连续失败计数归零后从
attempt 0 重新起;多次失败成功循环;部分成功算成功;前台
刷新下恢复;长失败链后恢复。共 8 个测试。
2026-08-31 21:06:26 +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, TASK_POLL_VISIBLE_INTERVAL_MS, '失败后仍按正常间隔续轮')
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 < 1000), false, '自动轮询阶段无退避定时器残留')
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()
})