import { test, expect, describe } from "vitest"; import { buildSchedule, executeScheduledSteps, ScheduleError, } from "../runtime/pipeline-scheduler.ts"; import type { KernelExecutionReport, KernelPipelineStep } from "@warpgogol/werkstatt-shared/kernel"; /* RFC-0686: Unit tests for the pipeline scheduler — buildSchedule and executeScheduledSteps. Covers backward-compatible sequential behavior, parallel execution, dependency waiting, failure propagation, cycle detection, explicit skip, and dependency-failure skip. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. Fixtures stay inline and minimal so each case states its own setup. RFC-1311: Compass header sweep — declares the pipeline suite's contract for test-file scaffold coverage. */ // @ai-invariant fixtures must not couple to internals beyond the public surface under test function okReport(command: string): KernelExecutionReport { return { commandName: command, exitCode: 0, ok: true, summary: `${command}: OK`, metadata: {} as never, logs: [], timing: { durationMs: 10, exceededTimeout: false }, filesModified: [], }; } function failReport(command: string): KernelExecutionReport { return { commandName: command, exitCode: 1, ok: false, summary: `${command}: FAIL`, metadata: {} as never, logs: [], timing: { durationMs: 10, exceededTimeout: false }, filesModified: [], }; } function skippedReport(command: string): KernelExecutionReport { return { commandName: command, exitCode: 0, ok: true, summary: `${command}: skipped`, metadata: {} as never, logs: [], timing: { durationMs: 0, exceededTimeout: false }, filesModified: [], }; } describe("buildSchedule", () => { test("(a) backward-compatible sequential behavior: steps without dependsOn get implicit dependency on previous", () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a" }, { command: "cmd.b" }, { command: "cmd.c" }, ]; const scheduled = buildSchedule(steps); expect(scheduled).toHaveLength(3); expect(scheduled[0]!.dependencies.size).toBe(0); expect(scheduled[1]!.dependencies.has(0)).toBe(true); expect(scheduled[2]!.dependencies.has(1)).toBe(true); }); test("(b) dependsOn: [] means no dependencies", () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a" }, { command: "cmd.b", dependsOn: [] }, { command: "cmd.c", dependsOn: [] }, ]; const scheduled = buildSchedule(steps); expect(scheduled[0]!.dependencies.size).toBe(0); expect(scheduled[1]!.dependencies.size).toBe(0); expect(scheduled[2]!.dependencies.size).toBe(0); }); test("(c) dependsOn resolves command names to indices", () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a" }, { command: "cmd.b", dependsOn: [] }, { command: "cmd.c", dependsOn: ["cmd.a"] }, ]; const scheduled = buildSchedule(steps); expect(scheduled[2]!.dependencies.has(0)).toBe(true); expect(scheduled[2]!.dependencies.size).toBe(1); }); test("(e) cycle detection throws ScheduleError", () => { // To create a cycle, we need dependsOn that references a later step. // But buildSchedule rejects forward references. So a true cycle can // only happen if dependsOn references a command that appears earlier // but also depends back. Since forward refs are rejected, cycles are // impossible with the current constraints. Test the error path anyway // by constructing a schedule manually and calling detectCycles indirectly. // Actually, cycles ARE impossible because forward references are rejected. // So we test that forward references throw instead. const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: ["cmd.b"] }, { command: "cmd.b" }, ]; expect(() => buildSchedule(steps)).toThrow(ScheduleError); expect(() => buildSchedule(steps)).toThrow(/forward reference/i); }); test("missing dependency throws ScheduleError", () => { const steps: KernelPipelineStep[] = [{ command: "cmd.a", dependsOn: ["cmd.nonexistent"] }]; expect(() => buildSchedule(steps)).toThrow(ScheduleError); expect(() => buildSchedule(steps)).toThrow(/not in the pipeline/i); }); test("duplicate command names throw ScheduleError", () => { const steps: KernelPipelineStep[] = [{ command: "cmd.a" }, { command: "cmd.a" }]; expect(() => buildSchedule(steps)).toThrow(ScheduleError); expect(() => buildSchedule(steps)).toThrow(/duplicate command name/i); }); test("skipped steps do not create implicit dependencies for subsequent steps", () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a" }, { command: "cmd.b", skip: true }, { command: "cmd.c" }, ]; const scheduled = buildSchedule(steps); // cmd.c should depend on cmd.a (the previous non-skipped step), not cmd.b expect(scheduled[2]!.dependencies.has(0)).toBe(true); expect(scheduled[2]!.dependencies.has(1)).toBe(false); }); }); describe("executeScheduledSteps", () => { test("(a) sequential execution: steps without dependsOn run in order", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a" }, { command: "cmd.b" }, { command: "cmd.c" }, ]; const scheduled = buildSchedule(steps); const executionOrder: string[] = []; const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { executionOrder.push(sStep.step.command); await new Promise((r) => setTimeout(r, 1)); return okReport(sStep.step.command); }); expect(results).toHaveLength(3); expect(executionOrder).toEqual(["cmd.a", "cmd.b", "cmd.c"]); // Results are sorted by stepIndex expect(results.map((r) => r.stepIndex)).toEqual([0, 1, 2]); }); test("(b) parallel execution of independent steps with dependsOn: []", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: [] }, { command: "cmd.c", dependsOn: [] }, ]; const scheduled = buildSchedule(steps); const startTimes: Record = {}; const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { startTimes[sStep.step.command] = Date.now(); await new Promise((r) => setTimeout(r, 50)); return okReport(sStep.step.command); }); expect(results).toHaveLength(3); // All three should start within a small window (concurrent) const starts = Object.values(startTimes); const spread = Math.max(...starts) - Math.min(...starts); expect(spread).toBeLessThan(30); // all started nearly simultaneously }); test("(c) dependency waiting: step with dependsOn waits for dependency", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: ["cmd.a"] }, ]; const scheduled = buildSchedule(steps); const executionOrder: string[] = []; const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { executionOrder.push(sStep.step.command); await new Promise((r) => setTimeout(r, 20)); return okReport(sStep.step.command); }); expect(results).toHaveLength(2); expect(executionOrder).toEqual(["cmd.a", "cmd.b"]); }); test("(d) failure propagation: failed step causes transitive dependents to be skipped", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: ["cmd.a"] }, { command: "cmd.c", dependsOn: ["cmd.b"] }, { command: "cmd.d", dependsOn: [] }, ]; const scheduled = buildSchedule(steps); const executed: string[] = []; const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { executed.push(sStep.step.command); await new Promise((r) => setTimeout(r, 10)); if (sStep.step.command === "cmd.a") return failReport("cmd.a"); return okReport(sStep.step.command); }); expect(results).toHaveLength(4); // cmd.a failed, cmd.b and cmd.c should be skipped, cmd.d should execute expect(executed).toContain("cmd.a"); expect(executed).toContain("cmd.d"); expect(executed).not.toContain("cmd.b"); expect(executed).not.toContain("cmd.c"); const skipped = results.filter((r) => r.dependencySkipped); expect(skipped.map((r) => r.stepIndex).sort()).toEqual([1, 2]); }); test("(h) explicit skip (step.skip === true) does not block dependents", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", skip: true, dependsOn: [] }, { command: "cmd.c", dependsOn: ["cmd.b"] }, ]; const scheduled = buildSchedule(steps); const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { if (sStep.step.skip) return skippedReport(sStep.step.command); return okReport(sStep.step.command); }); expect(results).toHaveLength(3); // cmd.c should still execute because cmd.b was explicitly skipped (not failed) const cResult = results.find((r) => r.stepIndex === 2)!; expect(cResult.dependencySkipped).toBe(false); expect(cResult.report.ok).toBe(true); }); test("(i) dependency-failure skip blocks dependents transitively", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: ["cmd.a"] }, { command: "cmd.c", dependsOn: ["cmd.b"] }, ]; const scheduled = buildSchedule(steps); const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { if (sStep.step.command === "cmd.a") return failReport("cmd.a"); return okReport(sStep.step.command); }); expect(results).toHaveLength(3); const aResult = results.find((r) => r.stepIndex === 0)!; expect(aResult.report.ok).toBe(false); expect(aResult.dependencySkipped).toBe(false); const bResult = results.find((r) => r.stepIndex === 1)!; expect(bResult.dependencySkipped).toBe(true); const cResult = results.find((r) => r.stepIndex === 2)!; expect(cResult.dependencySkipped).toBe(true); }); test("--concurrency 1 activates full sequential mode with abort-on-failure", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: [] }, { command: "cmd.c", dependsOn: [] }, ]; const scheduled = buildSchedule(steps); const executed: string[] = []; const results = await executeScheduledSteps(scheduled, 1, async (sStep) => { executed.push(sStep.step.command); await new Promise((r) => setTimeout(r, 5)); if (sStep.step.command === "cmd.a") return failReport("cmd.a"); return okReport(sStep.step.command); }); expect(results).toHaveLength(3); // With concurrency=1, steps run sequentially and abort on failure expect(executed).toEqual(["cmd.a"]); // only cmd.a executed, rest skipped const skipped = results.filter((r) => r.dependencySkipped); expect(skipped).toHaveLength(2); }); test("results are sorted by stepIndex regardless of completion order", async () => { const steps: KernelPipelineStep[] = [ { command: "cmd.a", dependsOn: [] }, { command: "cmd.b", dependsOn: [] }, { command: "cmd.c", dependsOn: [] }, ]; const scheduled = buildSchedule(steps); const results = await executeScheduledSteps(scheduled, 4, async (sStep) => { // cmd.c finishes first, cmd.a finishes last const delay = sStep.step.command === "cmd.a" ? 50 : sStep.step.command === "cmd.b" ? 30 : 10; await new Promise((r) => setTimeout(r, delay)); return okReport(sStep.step.command); }); expect(results.map((r) => r.stepIndex)).toEqual([0, 1, 2]); }); });