Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
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();
});
});