diff --git a/frontend-vue/src/shared/release-checklist.ts b/frontend-vue/src/shared/release-checklist.ts new file mode 100644 index 00000000..f8898b16 --- /dev/null +++ b/frontend-vue/src/shared/release-checklist.ts @@ -0,0 +1,139 @@ +/** + * 发布前检查清单(Task 100)。 + * + * 收敛发布前的三个验收步骤:回滚演练、git commit 对应关系检查、 + * 交付清单核对。每步由 deps 提供真实实现(SSH 回滚、git log、 + * 产物校验),清单产出结构化结果。 + * + * 语义: + * - 回滚演练失败或 commit 缺失时整单 ok=false,但不中断后续步骤, + * 其余检查照常产出完整报告; + * - releaseCommits 为空、deliverables 为空:安全跳过对应检查,视为通过; + * - maxCommits 限制 commit 检查数量(超出部分计入 unverified),检查有界; + * - deps 抛错(SSH/git 故障):整轮 run 抛错,零部分结果,依赖恢复后 + * 同一清单重跑即全绿; + * - 校验失败 fail-fast:deps 非对象、缺函数、数组字段非法、maxCommits + * 非正数、commit 记录缺 hash 均抛错。 + */ +export interface ReleaseCommit { + hash: string + subject?: string +} + +export interface RollbackResult { + ok: boolean + output: string +} + +export interface ReleaseCheckDeps { + /** 列出当前仓库 commit(含 hash 与 subject) */ + listCommits: () => Promise + /** 执行回滚演练 */ + rollback: () => Promise + /** 验证发布后页面/核心请求 */ + verify: () => Promise +} + +export interface ReleaseChecklistOptions { + deps: ReleaseCheckDeps + /** 本次发布对应的 commit hash 列表 */ + releaseCommits: string[] + /** 交付物清单(jar、exe、vue-dist 等) */ + deliverables?: string[] + /** commit 检查数量上限,必须为正数,默认 100 */ + maxCommits?: number +} + +export interface ReleaseCheckResult { + ok: boolean + steps: Array<{ id: string; ok: boolean }> + rollback: RollbackResult + verify: RollbackResult + commitMap: { + checked: number + missing: string[] + unverified: string[] + } + deliverables: { + passed: boolean + total: number + items: string[] + } +} + +export interface ReleaseChecklist { + run: () => Promise +} + +export function createReleaseChecklist(options: ReleaseChecklistOptions): ReleaseChecklist { + if (typeof options !== 'object' || options == null || !options.deps) { + throw new Error('deps 必须是对象') + } + if (typeof options.deps.listCommits !== 'function') { + throw new Error('listCommits 必须是函数') + } + if (typeof options.deps.rollback !== 'function') { + throw new Error('rollback 必须是函数') + } + if (typeof options.deps.verify !== 'function') { + throw new Error('verify 必须是函数') + } + if (!Array.isArray(options.releaseCommits)) { + throw new Error('releaseCommits 必须是数组') + } + const deliverables = options.deliverables ?? [] + if (!Array.isArray(deliverables)) { + throw new Error('deliverables 必须是数组') + } + const maxCommits = options.maxCommits ?? 100 + if (!(maxCommits > 0)) { + throw new Error('maxCommits 必须为正数: ' + maxCommits) + } + + async function run(): Promise { + const steps: Array<{ id: string; ok: boolean }> = [] + let ok = true + + const rollback = await options.deps.rollback() + steps.push({ id: 'rollback-drill', ok: rollback.ok }) + if (!rollback.ok) ok = false + + const commits = await options.deps.listCommits() + const available = new Set() + for (const commit of commits) { + if (typeof commit.hash !== 'string' || commit.hash.length === 0) { + throw new Error('commit 缺少 hash') + } + available.add(commit.hash) + } + const missing: string[] = [] + const unverified: string[] = [] + for (let i = 0; i < options.releaseCommits.length; i++) { + const hash = options.releaseCommits[i] + if (i >= maxCommits) { + unverified.push(hash) + continue + } + if (!available.has(hash)) { + missing.push(hash) + ok = false + } + } + steps.push({ id: 'commit-map', ok: missing.length === 0 && unverified.length === 0 }) + + const verify = await options.deps.verify() + steps.push({ id: 'deliverables', ok: verify.ok }) + if (!verify.ok) ok = false + + return { + ok, + steps, + rollback, + verify, + commitMap: { checked: options.releaseCommits.length, missing, unverified }, + deliverables: { passed: verify.ok, total: deliverables.length, items: [...deliverables] }, + } + } + + return { run } +} diff --git a/frontend-vue/tests/release-checklist.test.ts b/frontend-vue/tests/release-checklist.test.ts new file mode 100644 index 00000000..f2983a96 --- /dev/null +++ b/frontend-vue/tests/release-checklist.test.ts @@ -0,0 +1,164 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createReleaseChecklist, type ReleaseCheckDeps } from '../src/shared/release-checklist.ts' + +const COMMITS = [ + { hash: 'abc123', subject: 'task-99: 全链路压测记录器' }, + { hash: 'def456', subject: 'progress 99' }, + { hash: 'ghi789', subject: 'task-98: 健康检查执行器' }, + { hash: 'jkl012', subject: 'progress 98' }, +] + +function makeDeps(over: Partial = {}): ReleaseCheckDeps & { calls: string[] } { + const calls: string[] = [] + return { + calls, + listCommits: async () => { + calls.push('list') + return COMMITS + }, + rollback: async () => { + calls.push('rollback') + return { ok: true, output: '回滚成功' } + }, + verify: async () => { + calls.push('verify') + return { ok: true, output: '页面正常' } + }, + ...over, + } +} + +test('test_task_100_task_normal_default_path', async () => { + const deps = makeDeps() + const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] }) + const result = await checklist.run() + assert.equal(result.ok, true) + assert.equal(result.steps.length, 3) + assert.deepEqual(result.steps.map((s) => s.id), ['rollback-drill', 'commit-map', 'deliverables']) + assert.equal(result.rollback.ok, true) + assert.equal(result.commitMap.checked, 2) + assert.equal(result.commitMap.missing.length, 0) + assert.equal(result.commitMap.unverified.length, 0) + assert.equal(result.deliverables.passed, true) + assert.equal(result.deliverables.total, 0, '默认无交付物清单') + assert.deepEqual(deps.calls, ['rollback', 'list', 'verify']) +}) + +test('test_task_100_task_normal_multiple_items', async () => { + // 批量交付物清单:顺序稳定、不丢失 + const deps = makeDeps() + const checklist = createReleaseChecklist({ + deps, + releaseCommits: ['abc123', 'def456', 'ghi789', 'jkl012'], + deliverables: ['jar', 'exe', 'vue-dist', 'python-backend'], + }) + const result = await checklist.run() + assert.equal(result.deliverables.total, 4) + assert.deepEqual(result.deliverables.items, ['jar', 'exe', 'vue-dist', 'python-backend']) + assert.equal(result.commitMap.checked, 4) + assert.deepEqual(result.commitMap.missing, []) +}) + +test('test_task_100_task_normal_repeated_operation_is_idempotent', async () => { + const deps = makeDeps() + const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] }) + const first = await checklist.run() + const second = await checklist.run() + assert.equal(first.ok, second.ok) + assert.deepEqual(first.commitMap.missing, second.commitMap.missing) + // 重复执行不产生重复状态:回滚演练与验证都恰好各执行两次(每轮一次) + assert.equal(deps.calls.filter((c) => c === 'rollback').length, 2) + assert.equal(deps.calls.filter((c) => c === 'verify').length, 2) +}) + +test('test_task_100_task_boundary_empty_input', async () => { + const deps = makeDeps({ listCommits: async () => [] }) + const checklist = createReleaseChecklist({ deps, releaseCommits: [] }) + const result = await checklist.run() + assert.equal(result.commitMap.checked, 0) + assert.deepEqual(result.commitMap.missing, []) + assert.equal(result.deliverables.total, 0) + assert.equal(result.deliverables.passed, true) + assert.equal(result.ok, true) +}) + +test('test_task_100_task_boundary_single_item', async () => { + const deps = makeDeps() + const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'], deliverables: ['jar'] }) + const result = await checklist.run() + assert.equal(result.commitMap.checked, 1) + assert.equal(result.commitMap.missing.length, 0) + assert.equal(result.deliverables.total, 1) + assert.equal(result.ok, true) +}) + +test('test_task_100_task_boundary_limit_and_overflow', async () => { + // 回滚演练失败:整个清单 ok=false,但 commit 检查与交付清单照常产出 + const deps = makeDeps({ + rollback: async () => ({ ok: false, output: '回滚失败:服务未恢复' }), + }) + const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123', 'ghi789'] }) + const result = await checklist.run() + assert.equal(result.ok, false) + assert.equal(result.rollback.ok, false) + assert.equal(result.commitMap.checked, 2, '失败不中断后续步骤') + assert.equal(result.deliverables.passed, true) + // 缺失 commit:标记 missing,不中断 + const deps2 = makeDeps({ listCommits: async () => COMMITS.slice(0, 1) }) + const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123', 'ghi789'] }) + const result2 = await checklist2.run() + assert.equal(result2.commitMap.missing.length, 1) + assert.equal(result2.commitMap.missing[0], 'ghi789') + assert.equal(result2.ok, false) +}) + +test('test_task_100_task_invalid_input_rejected', async () => { + assert.throws(() => createReleaseChecklist({} as never), /deps 必须是对象/) + const noList = makeDeps() + delete (noList as Partial).listCommits + assert.throws(() => createReleaseChecklist({ deps: noList as never, releaseCommits: [] }), /listCommits 必须是函数/) + assert.throws(() => createReleaseChecklist({ deps: makeDeps(), releaseCommits: 'abc' as never }), /releaseCommits 必须是数组/) + assert.throws( + () => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], deliverables: 'jar' as never }), + /deliverables 必须是数组/, + ) + assert.throws( + () => createReleaseChecklist({ deps: makeDeps(), releaseCommits: [], maxCommits: 0 }), + /maxCommits 必须为正数/, + ) + // listCommits 返回缺 hash 的记录:抛错 + const bad = makeDeps({ listCommits: async () => [{ subject: 'x' }] as never }) + await assert.rejects(() => createReleaseChecklist({ deps: bad, releaseCommits: ['a'] }).run(), /commit 缺少 hash/) +}) + +test('test_task_100_task_dependency_failure_releases_resources', async () => { + // 回滚演练依赖抛错:整轮失败但状态可恢复,修复后重跑成功 + let broken = true + const deps = makeDeps({ + rollback: async () => { + if (broken) throw new Error('ssh down') + return { ok: true, output: '回滚成功' } + }, + }) + const checklist = createReleaseChecklist({ deps, releaseCommits: ['abc123'] }) + await assert.rejects(() => checklist.run(), /ssh down/) + broken = false + const result = await checklist.run() + assert.equal(result.ok, true) + assert.equal(result.rollback.ok, true) + // commit 列表读取抛错同样可恢复 + let listBroken = true + const deps2 = makeDeps({ + listCommits: async () => { + if (listBroken) throw new Error('git down') + return COMMITS + }, + }) + const checklist2 = createReleaseChecklist({ deps: deps2, releaseCommits: ['abc123'] }) + await assert.rejects(() => checklist2.run(), /git down/) + listBroken = false + const result2 = await checklist2.run() + assert.equal(result2.ok, true) + assert.equal(result2.commitMap.checked, 1) +})