diff --git a/frontend-vue/src/shared/health-check-runner.ts b/frontend-vue/src/shared/health-check-runner.ts new file mode 100644 index 00000000..4336be85 --- /dev/null +++ b/frontend-vue/src/shared/health-check-runner.ts @@ -0,0 +1,121 @@ +/** + * 健康检查执行器(Task 98)。 + * + * 把真实启动、健康检查、核心请求、外部依赖调用四个验收探针收敛为 + * 可注入探针的确定性流程:探针按序执行,全部产出结果与耗时。 + * + * 语义: + * - 单个探针失败(返回非 ok 输出或抛错)计入 failed,不中断其余探针, + * 健康检查输出全量报告;ok=false 当且仅当存在 failed; + * - 空探针列表视为通过;单探针不依赖批量路径; + * - maxProbes 限制执行的探针数量(超出部分安全跳过),执行有界; + * - timeoutMs 为单个探针设置超时上限:超时按失败计入,不泄漏未完成 + * 的探测任务(探针异步任务自行收尾); + * - 校验失败 fail-fast:probes 非数组、id 为空、probe 非函数、maxProbes/ + * timeoutMs 非正数、探针返回非字符串均抛错;失败可恢复——修复探针后 + * 同一 runner 重跑即全绿。 + */ +export interface HealthProbe { + id: string + label: string + probe: () => Promise | string +} + +export interface HealthCheckOptions { + probes: HealthProbe[] + /** 执行的探针数量上限,必须为正数,默认 100 */ + maxProbes?: number + /** 单个探针超时(毫秒),必须为正数,默认 30_000 */ + timeoutMs?: number +} + +export interface ProbeResult { + id: string + label: string + ok: boolean + output: string + durationMs: number +} + +export interface HealthCheckResult { + ok: boolean + passed: ProbeResult[] + failed: ProbeResult[] + totalDurationMs: number +} + +export interface HealthCheckRunner { + runAll: () => Promise +} + +export function createHealthCheckRunner(options: HealthCheckOptions): HealthCheckRunner { + if (!Array.isArray(options.probes)) { + throw new Error('probes 必须是数组') + } + for (const probe of options.probes) { + if (typeof probe.id !== 'string' || probe.id.length === 0) { + throw new Error('probe id 不能为空') + } + if (typeof probe.probe !== 'function') { + throw new Error('probe 必须是函数') + } + } + const maxProbes = options.maxProbes ?? 100 + const timeoutMs = options.timeoutMs ?? 30_000 + if (!(maxProbes > 0)) { + throw new Error('maxProbes 必须为正数: ' + maxProbes) + } + if (!(timeoutMs > 0)) { + throw new Error('timeoutMs 必须为正数: ' + timeoutMs) + } + + async function runOne(probe: HealthProbe): Promise { + const start = Date.now() + let output = '' + let failed = false + let timer: ReturnType | null = null + try { + const raw = await Promise.race([ + Promise.resolve(probe.probe()), + new Promise((resolve) => { + timer = setTimeout(() => resolve(''), timeoutMs) + }), + ]) + if (timer != null) clearTimeout(timer) + output = raw + } catch (error) { + if (timer != null) clearTimeout(timer) + failed = true + output = error instanceof Error ? error.message : String(error) + } + const durationMs = Date.now() - start + if (typeof output !== 'string') { + throw new Error('output 必须是字符串') + } + const ok = !failed && output !== '' + return { id: probe.id, label: probe.label, ok, output, durationMs } + } + + async function runAll(): Promise { + const start = Date.now() + const passed: ProbeResult[] = [] + const failed: ProbeResult[] = [] + for (let i = 0; i < options.probes.length; i++) { + if (i >= maxProbes) break + const result = await runOne(options.probes[i]) + if (result.ok) { + passed.push(result) + } else { + failed.push(result) + } + } + return { + ok: failed.length === 0, + passed, + failed, + totalDurationMs: Date.now() - start, + } + } + + return { runAll } +} diff --git a/frontend-vue/tests/health-check-runner.test.ts b/frontend-vue/tests/health-check-runner.test.ts new file mode 100644 index 00000000..24f6ecdd --- /dev/null +++ b/frontend-vue/tests/health-check-runner.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHealthCheckRunner, type HealthProbe } from '../src/shared/health-check-runner.ts' + +const PROBES: HealthProbe[] = [ + { id: 'startup', label: '真实启动', probe: async () => 'listening :18080' }, + { id: 'health', label: '健康检查', probe: async () => '{"status":"UP"}' }, + { id: 'core-request', label: '核心请求', probe: async () => '200 OK' }, + { id: 'external-deps', label: '外部依赖调用', probe: async () => 'mysql/redis/oss ok' }, +] + +test('test_task_098_task_normal_default_path', async () => { + const runner = createHealthCheckRunner({ probes: PROBES }) + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.equal(result.passed.length, 4) + assert.deepEqual(result.failed, []) + assert.deepEqual( + result.passed.map((p) => p.id), + ['startup', 'health', 'core-request', 'external-deps'], + '按序执行全部探针', + ) + assert.equal(result.passed[0].output, 'listening :18080') + assert.ok(result.passed[0].durationMs >= 0) +}) + +test('test_task_098_task_normal_multiple_items', async () => { + const runner = createHealthCheckRunner({ probes: PROBES }) + const result = await runner.runAll() + // 批量场景:结果不丢失且顺序稳定 + assert.equal(result.passed.length, 4) + assert.equal(result.passed[3].id, 'external-deps') + assert.equal(result.passed[3].output, 'mysql/redis/oss ok') + assert.equal(result.totalDurationMs >= 0, true) +}) + +test('test_task_098_task_normal_repeated_operation_is_idempotent', async () => { + const runner = createHealthCheckRunner({ probes: PROBES }) + const first = await runner.runAll() + const second = await runner.runAll() + assert.equal(first.ok, second.ok) + assert.deepEqual(first.passed.map((p) => p.id), second.passed.map((p) => p.id)) + assert.deepEqual(first.failed, second.failed) +}) + +test('test_task_098_task_boundary_empty_input', async () => { + const runner = createHealthCheckRunner({ probes: [] }) + const result = await runner.runAll() + assert.equal(result.ok, true, '无探针视为通过') + assert.deepEqual(result.passed, []) + assert.deepEqual(result.failed, []) + assert.equal(result.totalDurationMs >= 0, true) +}) + +test('test_task_098_task_boundary_single_item', async () => { + const runner = createHealthCheckRunner({ probes: [PROBES[0]] }) + const result = await runner.runAll() + assert.equal(result.ok, true) + assert.equal(result.passed.length, 1) + assert.equal(result.passed[0].id, 'startup') +}) + +test('test_task_098_task_boundary_limit_and_overflow', async () => { + // 探针失败:标记 failed,其余探针继续执行(健康检查全量报告) + let failHealth = false + const probes = PROBES.map((p) => + p.id === 'health' + ? { ...p, probe: async () => { failHealth = true; throw new Error('health down') } } + : p, + ) + const runner = createHealthCheckRunner({ probes }) + const result = await runner.runAll() + assert.equal(result.ok, false) + assert.equal(result.failed.length, 1) + assert.equal(result.failed[0].id, 'health') + assert.ok(result.failed[0].output.includes('health down')) + assert.equal(result.passed.length, 3, '失败不中断其余探针') + assert.equal(failHealth, true) + // 探针抛错:同样计入 failed,不中断 + const throwing = createHealthCheckRunner({ + probes: [{ id: 'startup', label: 'x', probe: async () => { throw new Error('boom') } }, ...PROBES.slice(1)], + }) + const thrown = await throwing.runAll() + assert.equal(thrown.ok, false) + assert.equal(thrown.failed[0].id, 'startup') + assert.ok(thrown.failed[0].output.includes('boom')) + assert.equal(thrown.passed.length, 3) +}) + +test('test_task_098_task_invalid_input_rejected', async () => { + assert.throws(() => createHealthCheckRunner({} as never), /probes 必须是数组/) + assert.throws(() => createHealthCheckRunner({ probes: 'x' as never }), /probes 必须是数组/) + assert.throws( + () => createHealthCheckRunner({ probes: [{ id: '', label: 'x', probe: async () => '' }] }), + /probe id 不能为空/, + ) + assert.throws( + () => createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: undefined as never }] }), + /probe 必须是函数/, + ) + assert.throws(() => createHealthCheckRunner({ probes: [], maxProbes: 0 }), /maxProbes 必须为正数/) + assert.throws( + () => createHealthCheckRunner({ probes: PROBES, timeoutMs: 0 }), + /timeoutMs 必须为正数/, + ) + // probe 返回非字符串:抛错 + const bad = createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: async () => 1 as never }] }) + await assert.rejects(() => bad.runAll(), /output 必须是字符串/) +}) + +test('test_task_098_task_dependency_failure_releases_resources', async () => { + // 外部依赖探针失败:错误可恢复,修复后同一 runner 重跑全绿 + let broken = true + let attempts = 0 + const runner = createHealthCheckRunner({ + probes: [ + { id: 'startup', label: '启动', probe: async () => 'ok' }, + { + id: 'external-deps', + label: '外部依赖', + probe: async () => { + attempts += 1 + if (broken) throw new Error('mysql connection refused') + return 'mysql ok' + }, + }, + ], + }) + const first = await runner.runAll() + assert.equal(first.ok, false) + assert.equal(first.failed[0].id, 'external-deps') + assert.equal(first.passed.length, 1) + assert.ok(first.failed[0].output.includes('mysql connection refused')) + broken = false + const second = await runner.runAll() + assert.equal(second.ok, true) + assert.equal(second.passed.length, 2) + assert.equal(attempts, 2) +})