diff --git a/frontend-vue/src/shared/verification-runner.ts b/frontend-vue/src/shared/verification-runner.ts new file mode 100644 index 00000000..a85dddf9 --- /dev/null +++ b/frontend-vue/src/shared/verification-runner.ts @@ -0,0 +1,103 @@ +/** + * 三端验证工作流编排器(Task 97)。 + * + * 把 Java 全量测试、Python unittest、Vue 类型检查与构建四个验证步骤 + * 收敛为可注入执行器的确定性流程:步骤按序执行,产出逐项结果。 + * + * 语义: + * - 每个步骤的 run(stepId) 返回 { ok, output };ok=false 时计入 failed; + * - stopOnFailure(默认 true)下失败即停止,剩余步骤计入 skipped; + * - maxSteps 限制可执行步骤数量(超出部分计入 skipped),执行有界; + * - 步骤执行器抛错:整次 runAll 抛错,不产生部分结果记录(调用方可 + * 捕获后恢复重跑同一 runner); + * - 校验失败 fail-fast:steps 非数组、step id 为空、run 非函数、maxSteps + * 非正数、run 返回结果缺 output 均抛错。 + */ +export interface VerificationStep { + id: string + label: string +} + +export interface StepRunResult { + ok: boolean + output: string +} + +export interface VerificationRunnerDeps { + /** 执行单个步骤,返回结果;抛错时整次 runAll 抛错 */ + run: (stepId: string) => Promise +} + +export interface VerificationRunnerOptions { + steps: VerificationStep[] + run: (stepId: string) => Promise + /** 失败即停止(默认 true);false 时全部执行、失败累计 */ + stopOnFailure?: boolean + /** 可执行步骤数上限,必须为正数,默认 100 */ + maxSteps?: number +} + +export interface VerificationResult { + ok: boolean + passed: Array + failed: Array + skipped: string[] +} + +export interface VerificationRunner { + runAll: () => Promise +} + +export function createVerificationRunner(options: VerificationRunnerOptions): VerificationRunner { + if (!Array.isArray(options.steps)) { + throw new Error('steps 必须是数组') + } + for (const step of options.steps) { + if (typeof step.id !== 'string' || step.id.length === 0) { + throw new Error('step id 不能为空') + } + } + if (typeof options.run !== 'function') { + throw new Error('run 必须是函数') + } + const stopOnFailure = options.stopOnFailure ?? true + const maxSteps = options.maxSteps ?? 100 + if (!(maxSteps > 0)) { + throw new Error('maxSteps 必须为正数: ' + maxSteps) + } + + async function runAll(): Promise { + const passed: Array = [] + const failed: Array = [] + const skipped: string[] = [] + let ok = true + + for (let i = 0; i < options.steps.length; i++) { + const step = options.steps[i] + if (i >= maxSteps) { + skipped.push(step.id) + continue + } + const result = await options.run(step.id) + if (typeof result.output !== 'string') { + throw new Error('output 必须是字符串') + } + if (result.ok) { + passed.push({ ...step, output: result.output }) + } else { + ok = false + failed.push({ ...step, output: result.output }) + if (stopOnFailure) { + for (const rest of options.steps.slice(i + 1)) { + skipped.push(rest.id) + } + break + } + } + } + + return { ok, passed, failed, skipped } + } + + return { runAll } +} diff --git a/frontend-vue/tests/verification-runner.test.ts b/frontend-vue/tests/verification-runner.test.ts new file mode 100644 index 00000000..54d0397e --- /dev/null +++ b/frontend-vue/tests/verification-runner.test.ts @@ -0,0 +1,152 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createVerificationRunner, type VerificationRunnerDeps } from '../src/shared/verification-runner.ts' + +const STEPS = [ + { id: 'java-tests', label: 'Java 全量测试' }, + { id: 'python-unittest', label: 'Python unittest' }, + { id: 'vue-typecheck', label: 'Vue 类型检查' }, + { id: 'vue-build', label: 'Vue 构建' }, +] + +function makeDeps(over: Partial = {}): VerificationRunnerDeps & { runs: string[] } { + const runs: string[] = [] + return { + runs, + run: async (stepId: string) => { + runs.push(stepId) + return { ok: true, output: `${stepId} ok` } + }, + ...over, + } +} + +test('test_task_097_build_normal_default_path', async () => { + const deps = makeDeps() + const runner = createVerificationRunner({ steps: STEPS, run: deps.run }) + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.equal(result.passed.length, 4) + assert.deepEqual(result.failed, []) + assert.deepEqual(result.skipped, []) + assert.deepEqual(deps.runs, ['java-tests', 'python-unittest', 'vue-typecheck', 'vue-build'], '按序执行全部步骤') +}) + +test('test_task_097_build_normal_multiple_items', async () => { + const deps = makeDeps() + const runner = createVerificationRunner({ steps: STEPS, run: deps.run }) + const result = await runner.runAll() + // 批量场景:结果不丢失且顺序稳定 + assert.deepEqual( + result.passed.map((p) => p.id), + ['java-tests', 'python-unittest', 'vue-typecheck', 'vue-build'], + ) + assert.equal(result.passed[0].output, 'java-tests ok') + assert.equal(result.passed[3].output, 'vue-build ok') +}) + +test('test_task_097_build_normal_repeated_operation_is_idempotent', async () => { + const deps = makeDeps() + const runner = createVerificationRunner({ steps: STEPS, run: deps.run }) + const first = await runner.runAll() + const second = await runner.runAll() + assert.equal(first.ok, second.ok) + assert.deepEqual(first.passed.map((p) => p.stepId), second.passed.map((p) => p.stepId)) + // 重复执行不产生重复状态:步骤执行次数线性增长 + assert.deepEqual(deps.runs, [...STEPS.map((s) => s.id), ...STEPS.map((s) => s.id)]) +}) + +test('test_task_097_build_boundary_empty_input', async () => { + const deps = makeDeps() + const runner = createVerificationRunner({ steps: [], run: deps.run }) + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.deepEqual(result.passed, []) + assert.deepEqual(result.failed, []) + assert.deepEqual(deps.runs, [], '无步骤不执行任何命令') +}) + +test('test_task_097_build_boundary_single_item', async () => { + const deps = makeDeps() + const runner = createVerificationRunner({ steps: [STEPS[0]], run: deps.run }) + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.equal(result.passed.length, 1) + assert.equal(result.passed[0].id, 'java-tests') + assert.deepEqual(deps.runs, ['java-tests']) +}) + +test('test_task_097_build_boundary_limit_and_overflow', async () => { + // 失败即停止:失败后的步骤被跳过,不发生无界执行 + const deps = makeDeps({ + run: async (stepId: string) => { + deps.runs.push(stepId) + if (stepId === 'python-unittest') return { ok: false, output: '2 tests failed' } + return { ok: true, output: `${stepId} ok` } + }, + }) + const runner = createVerificationRunner({ steps: STEPS, run: deps.run, stopOnFailure: true }) + const result = await runner.runAll() + assert.equal(result.ok, false) + assert.deepEqual(result.failed.map((f) => f.id), ['python-unittest']) + assert.deepEqual(result.skipped, ['vue-typecheck', 'vue-build'], '失败后的步骤被跳过') + assert.deepEqual(deps.runs, ['java-tests', 'python-unittest']) + // 非 stopOnFailure:全部执行,失败累计 + const deps2 = makeDeps({ + run: async (stepId: string) => { + deps2.runs.push(stepId) + if (stepId === 'vue-typecheck') return { ok: false, output: 'type error' } + return { ok: true, output: `${stepId} ok` } + }, + }) + const runner2 = createVerificationRunner({ steps: STEPS, run: deps2.run, stopOnFailure: false }) + const result2 = await runner2.runAll() + assert.equal(result2.ok, false) + assert.equal(result2.failed.length, 1) + assert.equal(result2.passed.length, 3) + assert.deepEqual(result2.skipped, []) +}) + +test('test_task_097_build_invalid_input_rejected', async () => { + // 构造器校验同步抛错 + assert.throws(() => createVerificationRunner({} as never), /steps 必须是数组/) + assert.throws( + () => createVerificationRunner({ steps: 'x' as never, run: async () => ({ ok: true, output: '' }) }), + /steps 必须是数组/, + ) + assert.throws( + () => createVerificationRunner({ steps: [{ id: '', label: 'x' }], run: async () => ({ ok: true, output: '' }) }), + /step id 不能为空/, + ) + assert.throws( + () => createVerificationRunner({ steps: [], run: undefined as never }), + /run 必须是函数/, + ) + assert.throws( + () => createVerificationRunner({ steps: [], run: async () => ({ ok: true, output: '' }), maxSteps: 0 }), + /maxSteps 必须为正数/, + ) + // run 返回非法结果:抛错 + const runner = createVerificationRunner({ steps: STEPS, run: async () => ({ ok: true }) as never }) + await assert.rejects(() => runner.runAll(), /output 必须是字符串/) +}) + +test('test_task_097_build_dependency_failure_releases_resources', async () => { + // 命令执行器抛错:剩余步骤终止、结果标记失败,恢复后同一 runner 可重跑 + let broken = true + const deps = makeDeps({ + run: async (stepId: string) => { + if (broken) throw new Error('command spawn down') + deps.runs.push(stepId) + return { ok: true, output: `${stepId} ok` } + }, + }) + const runner = createVerificationRunner({ steps: STEPS, run: deps.run }) + await assert.rejects(() => runner.runAll(), /command spawn down/) + assert.deepEqual(deps.runs, [], '抛错时不产生执行记录') + broken = false + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.equal(result.passed.length, 4) + assert.deepEqual(deps.runs, STEPS.map((s) => s.id), '恢复后四端全部执行') +})