199 lines
6.1 KiB
TypeScript
199 lines
6.1 KiB
TypeScript
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();
|
|
});
|
|
});
|