task-89: 清理页面卸载时的所有 timer、请求和临时 URL

新增纯 TS 页面卸载清理注册表 page-cleanup-registry:统一登记 blob
临时 URL(dispose 时 revokeUrl 释放)、AbortController(abort)与
timer 清理委托(clearTimers)。dispose 幂等且后注册拒绝并计数;
revokeUrl 抛错不中断其余条目;maxTrackedUrls 上限只保留最近注册的
URL。8 个测试覆盖默认、批量、幂等、空、单元素、上限驱逐、非法 hooks
与 revoke 故障恢复。
This commit is contained in:
2026-08-30 22:57:04 +08:00
parent 79961170c3
commit bb2f200d76
2 changed files with 270 additions and 0 deletions
@@ -0,0 +1,154 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createPageCleanupRegistry } from '../src/shared/page-cleanup-registry.ts'
interface CleanupHooks {
clearTimers: () => void
revokeUrl: (url: string) => void
}
function createHooks(): CleanupHooks & { cleared: string[] } {
const cleared: string[] = []
return {
cleared,
clearTimers: () => cleared.push('timers'),
revokeUrl: (url: string) => cleared.push(`url:${url}`),
}
}
test('test_task_089_cleanup_normal_default_path', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
const url = registry.registerObjectUrl('blob:http://x/1')
assert.equal(url, 'blob:http://x/1')
assert.equal(registry.trackedUrlCount, 1)
assert.equal(registry.disposeCount, 0)
const result = registry.dispose()
assert.equal(result, 2)
assert.equal(registry.disposed, true)
assert.equal(registry.trackedUrlCount, 0)
assert.deepEqual(hooks.cleared, ['url:blob:http://x/1', 'timers'])
assert.equal(registry.disposeCount, 1)
})
test('test_task_089_cleanup_normal_multiple_items', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/1')
registry.registerObjectUrl('blob:http://x/2')
registry.registerObjectUrl('blob:http://x/3')
const aborted: string[] = []
const makeController = (n: number) =>
({ abort: () => aborted.push(`abort:${n}`) }) as unknown as AbortController
registry.registerRequestController(makeController(1))
registry.registerRequestController(makeController(2))
assert.equal(registry.trackedUrlCount, 3)
assert.equal(registry.requestCount, 2)
const result = registry.dispose()
assert.equal(result, 6)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.requestCount, 0)
assert.deepEqual(aborted, ['abort:1', 'abort:2'])
assert.deepEqual(
hooks.cleared,
['url:blob:http://x/1', 'url:blob:http://x/2', 'url:blob:http://x/3', 'timers'],
)
})
test('test_task_089_cleanup_normal_repeated_operation_is_idempotent', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
const url = registry.registerObjectUrl('blob:http://x/1')
registry.dispose()
registry.dispose()
registry.dispose()
assert.equal(registry.disposed, true)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(hooks.cleared.length, 2, '第二次及以后的 dispose 不再重复清理')
// dispose 后注册被拒绝
registry.registerObjectUrl('blob:http://x/2')
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.disposeCount, 1)
assert.equal(registry.rejectedCount, 1)
assert.equal(url, 'blob:http://x/1')
})
test('test_task_089_cleanup_boundary_empty_input', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
assert.equal(registry.trackedUrlCount, 0)
assert.equal(registry.requestCount, 0)
const result = registry.dispose()
assert.equal(result, 1, 'dispose 至少执行 clearTimers')
assert.deepEqual(hooks.cleared, ['timers'])
// 空字符串 URL 不注册
registry.registerObjectUrl('')
assert.equal(registry.trackedUrlCount, 0)
})
test('test_task_089_cleanup_boundary_single_item', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/single')
const result = registry.dispose()
assert.equal(result, 2)
assert.equal(hooks.cleared.length, 2)
assert.equal(hooks.cleared[0], 'url:blob:http://x/single')
assert.equal(hooks.cleared[1], 'timers')
})
test('test_task_089_cleanup_boundary_limit_and_overflow', () => {
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 3 })
for (let i = 1; i <= 5; i++) registry.registerObjectUrl(`blob:http://x/${i}`)
assert.equal(registry.trackedUrlCount, 3, '超过上限只保留最近 3 个')
const result = registry.dispose()
assert.equal(result, 4)
assert.ok(hooks.cleared.includes('url:blob:http://x/3'))
assert.ok(hooks.cleared.includes('url:blob:http://x/5'))
assert.ok(!hooks.cleared.includes('url:blob:http://x/1'), '被驱逐的 URL 不再由注册表 revoke')
assert.equal(hooks.cleared.length, 4)
})
test('test_task_089_cleanup_invalid_input_rejected', () => {
assert.throws(() => createPageCleanupRegistry({} as never), /clearTimers 必须是函数/)
assert.throws(
() => createPageCleanupRegistry({ clearTimers: () => {} } as never),
/revokeUrl 必须是函数/,
)
const hooks = createHooks()
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 0 })
assert.equal(registry.registerObjectUrl('blob:http://x/1'), 'blob:http://x/1', 'maxTrackedUrls 为 0 时不限上限')
assert.equal(registry.trackedUrlCount, 1)
})
test('test_task_089_cleanup_dependency_failure_releases_resources', () => {
let urlFailures = 0
const hooks: CleanupHooks & { cleared: string[] } = {
cleared: [],
clearTimers: () => hooks.cleared.push('timers'),
revokeUrl: (url) => {
if (url === 'blob:http://x/poison') {
urlFailures += 1
throw new Error('revoke down')
}
hooks.cleared.push(`url:${url}`)
},
}
const registry = createPageCleanupRegistry(hooks)
registry.registerObjectUrl('blob:http://x/a')
registry.registerObjectUrl('blob:http://x/poison')
registry.registerObjectUrl('blob:http://x/b')
// revoke 抛错不中断清理,全部条目仍被尝试释放
const result = registry.dispose()
assert.equal(result, 4)
assert.equal(urlFailures, 1)
assert.equal(registry.trackedUrlCount, 0)
assert.ok(hooks.cleared.includes('url:blob:http://x/a'))
assert.ok(hooks.cleared.includes('url:blob:http://x/b'))
assert.ok(hooks.cleared.includes('timers'))
// 错误可恢复:同一注册表 dispose 后状态一致(幂等)
registry.dispose()
assert.equal(registry.disposeCount, 1)
assert.equal(urlFailures, 1)
})