task-97: Java/Python/Vue 三端验证工作流编排器
This commit is contained in:
@@ -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<StepRunResult>
|
||||
}
|
||||
|
||||
export interface VerificationRunnerOptions {
|
||||
steps: VerificationStep[]
|
||||
run: (stepId: string) => Promise<StepRunResult>
|
||||
/** 失败即停止(默认 true);false 时全部执行、失败累计 */
|
||||
stopOnFailure?: boolean
|
||||
/** 可执行步骤数上限,必须为正数,默认 100 */
|
||||
maxSteps?: number
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
ok: boolean
|
||||
passed: Array<VerificationStep & { output: string }>
|
||||
failed: Array<VerificationStep & { output: string }>
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
export interface VerificationRunner {
|
||||
runAll: () => Promise<VerificationResult>
|
||||
}
|
||||
|
||||
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<VerificationResult> {
|
||||
const passed: Array<VerificationStep & { output: string }> = []
|
||||
const failed: Array<VerificationStep & { output: string }> = []
|
||||
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 }
|
||||
}
|
||||
@@ -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> = {}): 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), '恢复后四端全部执行')
|
||||
})
|
||||
Reference in New Issue
Block a user