Initial Commit
This commit is contained in:
198
packages/foundation/flow/workflow/src/steps/step/step.test.ts
Normal file
198
packages/foundation/flow/workflow/src/steps/step/step.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user