import { describe, expect, it } from "@suaveplan/testing/runner"; import { WORKFLOW_CODES, WorkflowError } from "../../errors/workflow-error.js"; import { createStep, Step } from "./step.js"; describe("Step", () => { it("runs and reports completed", { timeout: 5000 }, async () => { const step = createStep<{ x: number }, number>({ id: "s1", name: "double", execute: (ctx) => ctx.x * 2, }); const r = await step.run({ x: 3 }); expect(r.status).toBe("completed"); expect(r.result).toBe(6); expect(r.attempts).toBe(1); expect(step.hasGuard).toBe(false); expect(step.hasCompensation).toBe(false); expect(step.hasRetry).toBe(false); }); it("wraps a thrown guard into WORKFLOW.GUARD_REJECTED", { timeout: 5000, }, async () => { const step = createStep({ id: "g", name: "guarded", execute: () => "ok", guard: () => { throw new Error("boom"); }, }); try { await step.shouldExecute({}); throw new Error("should not reach"); } catch (e) { expect(e).toBeInstanceOf(WorkflowError); expect((e as WorkflowError).code).toBe( WORKFLOW_CODES.GUARD_REJECTED ); } }); it("wraps non-Error guard throw", { timeout: 5000 }, async () => { const step = createStep({ id: "g", name: "guarded", execute: () => "ok", guard: () => { const v: unknown = "string-guard"; throw v; }, }); try { await step.shouldExecute({}); throw new Error("should not reach"); } catch (e) { expect((e as WorkflowError).message).toMatch(/string-guard/); } }); it("skips when guard returns false", { timeout: 5000 }, async () => { const step = createStep<{ on: boolean }>({ id: "s1", name: "guarded", execute: () => "ran", guard: (ctx) => ctx.on, }); expect(await step.shouldExecute({ on: false })).toBe(false); expect(step.hasGuard).toBe(true); }); it("retries with retry config and surfaces last error", { timeout: 5000, }, async () => { let attempts = 0; const step = new Step<{ x: number }>({ id: "s1", name: "flaky", execute: () => { attempts++; throw new Error(`fail-${attempts}`); }, retry: { maxAttempts: 3, strategy: "fixed", delayMs: 1 }, }); const r = await step.run({ x: 1 }); expect(r.status).toBe("failed"); expect(r.attempts).toBe(3); expect(r.error?.message).toBe("fail-3"); expect(step.hasRetry).toBe(true); expect(step.retryConfig?.maxAttempts).toBe(3); }); it("wraps non-Error throwables", { timeout: 5000 }, async () => { const step = createStep({ id: "s1", name: "throws", execute: () => { const x: unknown = "literal"; throw x; }, }); const r = await step.run({}); expect(r.status).toBe("failed"); expect(r.error?.message).toBe("literal"); }); it("fails with timeout error when execute exceeds budget", { timeout: 5000, }, async () => { const step = createStep({ id: "s1", name: "slow", execute: () => new Promise((resolve) => { setTimeout(() => resolve(undefined), 50); }), timeout: 5, }); const r = await step.run({}); expect(r.status).toBe("failed"); expect(r.error?.message).toMatch(/timed out/); }); it("invokes compensate when defined", { timeout: 5000 }, async () => { let called = false; const step = createStep({ id: "s1", name: "comp", execute: () => undefined, compensate: () => { called = true; }, }); await step.compensate({}); expect(called).toBe(true); expect(step.hasCompensation).toBe(true); }); it("compensate is a no-op when undefined", { timeout: 5000 }, async () => { const step = createStep({ id: "s1", name: "no-comp", execute: () => undefined, }); await step.compensate({}); // no throw == pass expect(step.hasCompensation).toBe(false); }); it("uses the injected clock for timestamps", { timeout: 5000, }, async () => { const step = createStep({ id: "s1", name: "ts", execute: () => "ok", clock: () => 1000, }); const r = await step.run({}); expect(r.startedAt).toBe(1000); expect(r.completedAt).toBe(1000); }); it("fills retry defaults from partial config", { timeout: 5000 }, () => { const step = createStep({ id: "s1", name: "s", execute: () => 1, retry: {}, }); expect(step.retryConfig).toEqual({ maxAttempts: 3, strategy: "fixed", delayMs: 1000, maxDelayMs: 30000, backoffMultiplier: 2, }); }); it("toDefinition round-trips the config", { timeout: 5000 }, () => { const step = createStep({ id: "s1", name: "named", execute: () => undefined, retry: { maxAttempts: 2, strategy: "linear", delayMs: 10 }, timeout: 100, guard: () => true, compensate: () => undefined, }); const def = step.toDefinition(); expect(def.id).toBe("s1"); expect(def.name).toBe("named"); expect(def.timeout).toBe(100); expect(def.retry?.maxAttempts).toBe(2); expect(def.guard).toBeDefined(); expect(def.compensate).toBeDefined(); }); });