From aa67c1453c132e5d584a154fd4782aacf7d53b17 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 20:56:12 +0200 Subject: [PATCH] test(sf): full lifecycle coverage for ADR-011 P2 escalation feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21 vitest tests covering the entire escalation chain shipped this session. Each contract claim from prior PDD specs gets at least one verifying test: buildEscalationArtifact validation (4) - option count outside [2,4] → throws - duplicate option ids → throws - recommendation referencing unknown id → throws - happy path → version=1, taskId set, ISO createdAt writeEscalationArtifact + DB flag flips (3) - continueWithDefault=false → escalation_pending=1 - continueWithDefault=true → escalation_awaiting_review=1 - two writes flip the pair atomically (mutually exclusive) detectPendingEscalation (4) - empty slice → null - paused task → returns task id - awaiting_review tasks DO NOT pause - resolved (respondedAt set) tasks DO NOT pause resolveEscalation (5) - 'accept' selects recommendation - explicit option id resolves with userRationale persisted - invalid choice → status=invalid-choice with valid list - re-resolve → already-resolved - unknown task → not-found claimOverrideForInjection carry-forward (5) - no escalation → null - pending (unresolved) → null - resolved → returns block + sourceTaskId + sets DB flag=1 - second claim → null (race-safe idempotent) - clearTaskEscalationFlags preserves artifact path (audit trail) Provides regression protection for the full producer→consumer→ resolution→carry-forward path. All 21 pass against current head. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sf/tests/escalation-feature.test.ts | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/resources/extensions/sf/tests/escalation-feature.test.ts diff --git a/src/resources/extensions/sf/tests/escalation-feature.test.ts b/src/resources/extensions/sf/tests/escalation-feature.test.ts new file mode 100644 index 000000000..fec3a10d7 --- /dev/null +++ b/src/resources/extensions/sf/tests/escalation-feature.test.ts @@ -0,0 +1,301 @@ +/** + * Full lifecycle tests for ADR-011 P2 mid-execution escalation (gsd-2 ADR). + * + * Covers: + * - DB schema columns (is_sketch is for ADR-011 P1, not tested here) + * - buildEscalationArtifact validation rules + * - writeEscalationArtifact persists artifact + flips DB flag based on + * continueWithDefault + * - detectPendingEscalation only returns paused tasks (not awaiting-review) + * - resolveEscalation: accept / option-id / invalid-choice / already-resolved / + * not-found contract + * - claimOverrideForInjection: race-safe atomic claim, idempotent, returns + * null for unresolved escalations + */ + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { + buildEscalationArtifact, + claimOverrideForInjection, + detectPendingEscalation, + readEscalationArtifact, + resolveEscalation, + writeEscalationArtifact, +} from "../escalation.ts"; +import { + clearTaskEscalationFlags, + closeDatabase, + getSliceTasks, + getTask, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.ts"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sf-escalation-test-")); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Test", status: "pending" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Test Slice", + risk: "medium", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Task 1", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T02", + title: "Task 2", + }); +}); + +afterEach(() => { + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); +}); + +const baseArtifact = { + taskId: "T01", + sliceId: "S01", + milestoneId: "M001", + question: "Overwrite or fail?", + options: [ + { id: "overwrite", label: "Overwrite", tradeoffs: "lose data" }, + { id: "fail", label: "Fail", tradeoffs: "block progress" }, + ], + recommendation: "fail", + recommendationRationale: "data loss is irreversible", + continueWithDefault: false, +}; + +describe("buildEscalationArtifact validation", () => { + test("requires 2-4 options", () => { + assert.throws( + () => + buildEscalationArtifact({ + ...baseArtifact, + options: [{ id: "a", label: "A", tradeoffs: "" }], + }), + /between 2 and 4/, + ); + assert.throws( + () => + buildEscalationArtifact({ + ...baseArtifact, + options: [ + { id: "a", label: "A", tradeoffs: "" }, + { id: "b", label: "B", tradeoffs: "" }, + { id: "c", label: "C", tradeoffs: "" }, + { id: "d", label: "D", tradeoffs: "" }, + { id: "e", label: "E", tradeoffs: "" }, + ], + }), + /between 2 and 4/, + ); + }); + + test("rejects duplicate option ids", () => { + assert.throws( + () => + buildEscalationArtifact({ + ...baseArtifact, + options: [ + { id: "a", label: "A", tradeoffs: "" }, + { id: "a", label: "A2", tradeoffs: "" }, + ], + }), + /unique ids/, + ); + }); + + test("recommendation must reference an option id", () => { + assert.throws( + () => + buildEscalationArtifact({ + ...baseArtifact, + recommendation: "nonexistent", + }), + /not one of the option ids/, + ); + }); + + test("happy path returns artifact with version=1 + createdAt", () => { + const art = buildEscalationArtifact(baseArtifact); + assert.equal(art.version, 1); + assert.equal(art.taskId, "T01"); + assert.equal(art.recommendation, "fail"); + assert.match(art.createdAt, /^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe("writeEscalationArtifact + DB flag flip", () => { + test("continueWithDefault=false → escalation_pending=1", () => { + const art = buildEscalationArtifact(baseArtifact); + const path = writeEscalationArtifact(dir, art); + assert.match(path, /T01-ESCALATION\.json$/); + const t = getTask("M001", "S01", "T01"); + assert.equal(t?.escalation_pending, 1); + assert.equal(t?.escalation_awaiting_review, 0); + assert.equal(t?.escalation_artifact_path, path); + }); + + test("continueWithDefault=true → awaiting_review (no pause)", () => { + const art = buildEscalationArtifact({ + ...baseArtifact, + continueWithDefault: true, + }); + writeEscalationArtifact(dir, art); + const t = getTask("M001", "S01", "T01"); + assert.equal(t?.escalation_pending, 0); + assert.equal(t?.escalation_awaiting_review, 1); + }); + + test("two writes flip the flag pair atomically (mutually exclusive)", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + const t1 = getTask("M001", "S01", "T01"); + assert.equal(t1?.escalation_pending, 1); + assert.equal(t1?.escalation_awaiting_review, 0); + writeEscalationArtifact( + dir, + buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }), + ); + const t2 = getTask("M001", "S01", "T01"); + assert.equal(t2?.escalation_pending, 0); + assert.equal(t2?.escalation_awaiting_review, 1); + }); +}); + +describe("detectPendingEscalation", () => { + test("empty slice → null", () => { + assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null); + }); + + test("paused task → returns task id", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + assert.equal( + detectPendingEscalation(getSliceTasks("M001", "S01"), dir), + "T01", + ); + }); + + test("awaiting_review does NOT trigger pause", () => { + writeEscalationArtifact( + dir, + buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }), + ); + assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null); + }); + + test("respondedAt artifact does NOT trigger pause", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale"); + assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null); + }); +}); + +describe("resolveEscalation", () => { + beforeEach(() => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + }); + + test("accept selects the recommended option", () => { + const r = resolveEscalation(dir, "M001", "S01", "T01", "accept", ""); + assert.equal(r.status, "resolved"); + assert.equal(r.chosenOption?.id, "fail"); // recommendation + const t = getTask("M001", "S01", "T01"); + assert.equal(t?.escalation_pending, 0); + }); + + test("explicit option-id resolution works", () => { + const r = resolveEscalation( + dir, + "M001", + "S01", + "T01", + "overwrite", + "caller knows best", + ); + assert.equal(r.status, "resolved"); + assert.equal(r.chosenOption?.id, "overwrite"); + const art = readEscalationArtifact( + getTask("M001", "S01", "T01")!.escalation_artifact_path!, + ); + assert.equal(art?.userChoice, "overwrite"); + assert.equal(art?.userRationale, "caller knows best"); + }); + + test("invalid choice → invalid-choice with valid list", () => { + const r = resolveEscalation(dir, "M001", "S01", "T01", "banana", ""); + assert.equal(r.status, "invalid-choice"); + assert.match(r.message, /accept, overwrite, fail/); + }); + + test("re-resolve already-resolved → already-resolved status", () => { + resolveEscalation(dir, "M001", "S01", "T01", "fail", ""); + const r = resolveEscalation(dir, "M001", "S01", "T01", "overwrite", ""); + assert.equal(r.status, "already-resolved"); + }); + + test("unknown task → not-found", () => { + const r = resolveEscalation(dir, "M001", "S01", "T99", "accept", ""); + assert.equal(r.status, "not-found"); + }); +}); + +describe("claimOverrideForInjection (carry-forward)", () => { + test("no escalation in slice → null", () => { + assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null); + }); + + test("pending (unresolved) escalation → null (no carry-forward yet)", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null); + }); + + test("resolved escalation → returns block + sourceTaskId, sets DB flag", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale"); + const r = claimOverrideForInjection(dir, "M001", "S01"); + assert.ok(r); + assert.equal(r?.sourceTaskId, "T01"); + assert.match(r?.injectionBlock ?? "", /Escalation Override/); + assert.match(r?.injectionBlock ?? "", /User rationale/); + const t = getTask("M001", "S01", "T01"); + assert.equal(t?.escalation_override_applied, 1); + }); + + test("second claim → null (race-safe idempotent)", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + resolveEscalation(dir, "M001", "S01", "T01", "fail", ""); + assert.ok(claimOverrideForInjection(dir, "M001", "S01")); + assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null); + }); + + test("clearTaskEscalationFlags preserves artifact path for audit trail", () => { + writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact)); + const before = getTask("M001", "S01", "T01")!.escalation_artifact_path; + clearTaskEscalationFlags("M001", "S01", "T01"); + const after = getTask("M001", "S01", "T01"); + assert.equal(after?.escalation_pending, 0); + assert.equal(after?.escalation_awaiting_review, 0); + assert.equal(after?.escalation_artifact_path, before); + }); +});