task-93: Similar ASIN、店铺抓取和采集数据页面 E2E 核心路径执行器
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 页面 E2E 核心路径执行器(Task 93)。
|
||||
*
|
||||
* 把 Similar ASIN / 店铺抓取 / 采集数据三个页面的 E2E 核心路径收敛为
|
||||
* 可注入依赖的确定性流程:解析文件(parse)→ 建任务(createTask)→
|
||||
* 轮询到终态(poll)→ 刷新历史(refreshHistory),各步由 deps 提供实现,
|
||||
* 页面接入时传真实接口即可复用同一套语义。
|
||||
*
|
||||
* 流程语义:
|
||||
* - parse 返回 0 表示无可处理数据:安全跳过后续步骤,不创建无效资源;
|
||||
* - poll 返回非终态时按最大次数轮询,超过 maxPollAttempts 降级返回当前
|
||||
* 状态(不抛错、不无限轮询、不刷新历史);
|
||||
* - 任一步抛错:执行立即终止,cleanup 必被调用(失败与成功路径都执行),
|
||||
* 调用方可重新执行同一 deps 恢复;
|
||||
* - 输入校验 fail-fast:deps 缺函数、parse 返回非法 taskId、maxPollAttempts
|
||||
* 非法均抛错且零状态变更。
|
||||
*/
|
||||
export interface PageE2EDeps {
|
||||
/** 解析文件/参数,返回 taskId;返回 0 表示无可处理数据 */
|
||||
parse: () => Promise<number>
|
||||
/** 创建任务 */
|
||||
createTask: (taskId: number) => Promise<void>
|
||||
/** 单次轮询任务进度,返回状态字符串 */
|
||||
poll: (taskId: number) => Promise<string>
|
||||
/** 任务到达终态后刷新历史列表(可选) */
|
||||
refreshHistory?: () => Promise<void>
|
||||
/** 释放临时资源(URL、控制器、计时器等),失败与成功路径都会调用(可选) */
|
||||
cleanup?: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface PageE2EOptions {
|
||||
/** 轮询次数上限,必须为正数,默认 60 */
|
||||
maxPollAttempts?: number
|
||||
/** 判定终态,默认 SUCCESS/FAILED */
|
||||
isTerminal?: (status: string) => boolean
|
||||
}
|
||||
|
||||
export interface PageE2EResult {
|
||||
taskId: number
|
||||
status: string
|
||||
/** 已完成的步骤(按执行顺序) */
|
||||
completedSteps: string[]
|
||||
/** 实际轮询次数 */
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export async function runPageE2ECorePath(
|
||||
deps: PageE2EDeps,
|
||||
options: PageE2EOptions = {},
|
||||
): Promise<PageE2EResult> {
|
||||
if (typeof deps !== 'object' || deps == null) {
|
||||
throw new Error('deps 必须是对象')
|
||||
}
|
||||
if (typeof deps.parse !== 'function') {
|
||||
throw new Error('parse 必须是函数')
|
||||
}
|
||||
if (typeof deps.createTask !== 'function') {
|
||||
throw new Error('createTask 必须是函数')
|
||||
}
|
||||
if (typeof deps.poll !== 'function') {
|
||||
throw new Error('poll 必须是函数')
|
||||
}
|
||||
const maxPollAttempts = options.maxPollAttempts ?? 60
|
||||
if (!(maxPollAttempts > 0)) {
|
||||
throw new Error('maxPollAttempts 必须为正数: ' + maxPollAttempts)
|
||||
}
|
||||
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
|
||||
|
||||
const completedSteps: string[] = []
|
||||
let status = ''
|
||||
let attempts = 0
|
||||
|
||||
try {
|
||||
const taskId = await deps.parse()
|
||||
if (taskId === 0) {
|
||||
completedSteps.push('parse')
|
||||
return { taskId: 0, status: '', completedSteps, attempts: 0 }
|
||||
}
|
||||
if (typeof taskId !== 'number' || !Number.isFinite(taskId) || taskId <= 0) {
|
||||
throw new Error('parse 必须返回正整数 taskId: ' + taskId)
|
||||
}
|
||||
completedSteps.push('parse')
|
||||
await deps.createTask(taskId)
|
||||
completedSteps.push('create-task')
|
||||
while (attempts < maxPollAttempts) {
|
||||
attempts += 1
|
||||
status = await deps.poll(taskId)
|
||||
completedSteps.push('poll')
|
||||
if (isTerminal(status)) {
|
||||
await deps.refreshHistory?.()
|
||||
completedSteps.push('refresh-history')
|
||||
return { taskId, status, completedSteps, attempts }
|
||||
}
|
||||
}
|
||||
return { taskId, status, completedSteps, attempts }
|
||||
} finally {
|
||||
await deps.cleanup?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { runPageE2ECorePath, type PageE2EDeps } from '../src/shared/page-e2e-core-path.ts'
|
||||
|
||||
const terminal = (status: string) => status === 'SUCCESS' || status === 'FAILED'
|
||||
|
||||
function makeDeps(over: Partial<PageE2EDeps> = {}): PageE2EDeps & { calls: string[] } {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
parse: async () => {
|
||||
calls.push('parse')
|
||||
return 101
|
||||
},
|
||||
createTask: async (taskId: number) => {
|
||||
calls.push(`create:${taskId}`)
|
||||
},
|
||||
poll: async (taskId: number) => {
|
||||
calls.push(`poll:${taskId}`)
|
||||
return 'SUCCESS'
|
||||
},
|
||||
refreshHistory: async () => {
|
||||
calls.push('refresh')
|
||||
},
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
test('test_task_093_collect_asin_e2e_normal_default_path', async () => {
|
||||
const deps = makeDeps()
|
||||
const result = await runPageE2ECorePath(deps)
|
||||
assert.equal(result.taskId, 101)
|
||||
assert.equal(result.status, 'SUCCESS')
|
||||
assert.deepEqual(result.completedSteps, ['parse', 'create-task', 'poll', 'refresh-history'])
|
||||
assert.equal(result.attempts, 1)
|
||||
assert.deepEqual(deps.calls, ['parse', 'create:101', 'poll:101', 'refresh'])
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_normal_multiple_items', async () => {
|
||||
// 三个页面核心路径批量执行:结果不丢失且各页面顺序稳定
|
||||
const pages = ['similar-asin', 'shop-data-crawl', 'collect-data']
|
||||
const depss = pages.map((page, i) =>
|
||||
makeDeps({
|
||||
parse: async () => {
|
||||
depss[i].calls.push(`parse:${page}`)
|
||||
return 200 + i
|
||||
},
|
||||
poll: async () => {
|
||||
depss[i].calls.push(`poll:${page}`)
|
||||
return 'SUCCESS'
|
||||
},
|
||||
}),
|
||||
)
|
||||
const results = await Promise.all(pages.map((_, i) => runPageE2ECorePath(depss[i])))
|
||||
assert.equal(results.length, 3)
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.taskId),
|
||||
[200, 201, 202],
|
||||
)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.equal(results[i].status, 'SUCCESS')
|
||||
assert.deepEqual(results[i].completedSteps, ['parse', 'create-task', 'poll', 'refresh-history'])
|
||||
assert.deepEqual(depss[i].calls, [`parse:${pages[i]}`, `create:${200 + i}`, `poll:${pages[i]}`, 'refresh'])
|
||||
}
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_normal_repeated_operation_is_idempotent', async () => {
|
||||
const deps = makeDeps()
|
||||
const first = await runPageE2ECorePath(deps)
|
||||
const second = await runPageE2ECorePath(deps)
|
||||
assert.equal(first.taskId, second.taskId)
|
||||
assert.deepEqual(first.completedSteps, second.completedSteps)
|
||||
// 重复执行不产生重复状态:调用计数线性增长,无残留
|
||||
assert.equal(deps.calls.filter((c) => c === 'parse').length, 2)
|
||||
assert.equal(deps.calls.filter((c) => c === 'refresh').length, 2)
|
||||
assert.equal(deps.calls.length, 8)
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_boundary_empty_input', async () => {
|
||||
// parse 无可处理数据:安全跳过建任务/轮询/刷新,不创建无效资源
|
||||
const deps = makeDeps({ parse: async () => 0 })
|
||||
const result = await runPageE2ECorePath(deps)
|
||||
assert.equal(result.taskId, 0)
|
||||
assert.deepEqual(result.completedSteps, ['parse'])
|
||||
assert.deepEqual(deps.calls, [], 'parse 返回 0 时不再触发任何调用')
|
||||
// 空步骤序列等价处理:不执行任何步骤
|
||||
const empty = makeDeps({ parse: async () => 0 })
|
||||
assert.deepEqual((await runPageE2ECorePath(empty)).completedSteps, ['parse'])
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_boundary_single_item', async () => {
|
||||
// 单任务:一次轮询即终态,不依赖批量路径
|
||||
const deps = makeDeps()
|
||||
const result = await runPageE2ECorePath(deps, { maxPollAttempts: 3 })
|
||||
assert.equal(result.taskId, 101)
|
||||
assert.equal(result.attempts, 1)
|
||||
assert.equal(result.status, 'SUCCESS')
|
||||
assert.equal(deps.calls.filter((c) => c.startsWith('poll')).length, 1)
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_boundary_limit_and_overflow', async () => {
|
||||
// 轮询次数有界:超过 maxPollAttempts 降级返回当前状态,不发生无界轮询
|
||||
let pollCount = 0
|
||||
const deps = makeDeps({
|
||||
poll: async () => {
|
||||
pollCount += 1
|
||||
return 'RUNNING'
|
||||
},
|
||||
})
|
||||
const result = await runPageE2ECorePath(deps, { maxPollAttempts: 4 })
|
||||
assert.equal(result.status, 'RUNNING', '超过上限降级返回,不抛错')
|
||||
assert.equal(result.attempts, 4)
|
||||
assert.equal(pollCount, 4)
|
||||
assert.deepEqual(result.completedSteps, ['parse', 'create-task', 'poll', 'poll', 'poll', 'poll'])
|
||||
assert.ok(!result.completedSteps.includes('refresh-history'), '终态未达成不刷新历史')
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_invalid_input_rejected', async () => {
|
||||
await assert.rejects(() => runPageE2ECorePath(null as never), /deps 必须是对象/)
|
||||
await assert.rejects(() => runPageE2ECorePath({} as never), /parse 必须是函数/)
|
||||
await assert.rejects(
|
||||
() => runPageE2ECorePath({ parse: async () => 1 } as never),
|
||||
/createTask 必须是函数/,
|
||||
)
|
||||
const missingPoll = makeDeps()
|
||||
delete (missingPoll as Partial<PageE2EDeps>).poll
|
||||
await assert.rejects(() => runPageE2ECorePath(missingPoll as never), /poll 必须是函数/)
|
||||
const deps = makeDeps()
|
||||
await assert.rejects(() => runPageE2ECorePath(deps, { maxPollAttempts: 0 }), /maxPollAttempts 必须为正数/)
|
||||
await assert.rejects(() => runPageE2ECorePath(deps, { maxPollAttempts: -1 }), /maxPollAttempts 必须为正数/)
|
||||
// parse 返回非法 taskId
|
||||
const badTask = makeDeps({ parse: async () => -5 })
|
||||
await assert.rejects(() => runPageE2ECorePath(badTask), /parse 必须返回正整数 taskId/)
|
||||
})
|
||||
|
||||
test('test_task_093_collect_asin_e2e_dependency_failure_releases_resources', async () => {
|
||||
// 断网:poll 抛错 → 执行终止、cleanup 释放资源;恢复后同一 deps 重跑成功
|
||||
let broken = true
|
||||
let cleaned = 0
|
||||
const deps = makeDeps({
|
||||
poll: async (taskId: number) => {
|
||||
if (broken) throw new Error('Network Error')
|
||||
return 'SUCCESS'
|
||||
},
|
||||
cleanup: async () => {
|
||||
cleaned += 1
|
||||
},
|
||||
})
|
||||
await assert.rejects(() => runPageE2ECorePath(deps), /Network Error/)
|
||||
assert.equal(cleaned, 1, '失败后 cleanup 释放资源')
|
||||
broken = false
|
||||
const result = await runPageE2ECorePath(deps)
|
||||
assert.equal(result.status, 'SUCCESS')
|
||||
assert.equal(cleaned, 2, '成功路径同样清理')
|
||||
assert.equal(result.taskId, 101)
|
||||
// 中途失败(createTask 抛错)→ 剩余步骤跳过 + cleanup 执行
|
||||
let createFailed = true
|
||||
const deps2 = makeDeps({
|
||||
createTask: async (taskId: number) => {
|
||||
if (createFailed) throw new Error('create down')
|
||||
deps2.calls.push(`create:${taskId}`)
|
||||
},
|
||||
cleanup: async () => {
|
||||
cleaned += 1
|
||||
},
|
||||
})
|
||||
await assert.rejects(() => runPageE2ECorePath(deps2), /create down/)
|
||||
assert.equal(cleaned, 3)
|
||||
assert.deepEqual(deps2.calls, ['parse'], 'create 失败后不再 poll/refresh')
|
||||
createFailed = false
|
||||
const retry = await runPageE2ECorePath(deps2)
|
||||
assert.equal(retry.status, 'SUCCESS')
|
||||
assert.equal(cleaned, 4)
|
||||
})
|
||||
Reference in New Issue
Block a user