From 85c8c66c4dbef7ba47ad65613ebc672e18f6b090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 23:08:56 +0800 Subject: [PATCH] =?UTF-8?q?task-93:=20Similar=20ASIN=E3=80=81=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E6=8A=93=E5=8F=96=E5=92=8C=E9=87=87=E9=9B=86=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A1=B5=E9=9D=A2=20E2E=20=E6=A0=B8=E5=BF=83=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=89=A7=E8=A1=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-vue/src/shared/page-e2e-core-path.ts | 99 ++++++++++ frontend-vue/tests/page-e2e-core-path.test.ts | 174 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 frontend-vue/src/shared/page-e2e-core-path.ts create mode 100644 frontend-vue/tests/page-e2e-core-path.test.ts diff --git a/frontend-vue/src/shared/page-e2e-core-path.ts b/frontend-vue/src/shared/page-e2e-core-path.ts new file mode 100644 index 00000000..e4185145 --- /dev/null +++ b/frontend-vue/src/shared/page-e2e-core-path.ts @@ -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 + /** 创建任务 */ + createTask: (taskId: number) => Promise + /** 单次轮询任务进度,返回状态字符串 */ + poll: (taskId: number) => Promise + /** 任务到达终态后刷新历史列表(可选) */ + refreshHistory?: () => Promise + /** 释放临时资源(URL、控制器、计时器等),失败与成功路径都会调用(可选) */ + cleanup?: () => Promise +} + +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 { + 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?.() + } +} diff --git a/frontend-vue/tests/page-e2e-core-path.test.ts b/frontend-vue/tests/page-e2e-core-path.test.ts new file mode 100644 index 00000000..eb72a972 --- /dev/null +++ b/frontend-vue/tests/page-e2e-core-path.test.ts @@ -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 & { 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).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) +})