import { expect, test } from "@playwright/test"; /** * Stage-6 closure smoke — saga orchestration across two plugins. * * The orchestrator owns the saga (`plugin-a`); `plugin-a` exports * step-1 + step-3, `plugin-b` exports step-2. The smoke runs the * happy path, then a step-2-failure path that triggers reverse-order * compensation across the two plugins. */ interface SagaInstance { readonly id: string; readonly state: string; readonly pluginName: string; readonly payload: { readonly sagaName: string; readonly currentStepIndex: number; readonly tenantId: string; }; } interface SagaStep { readonly sagaId: string; readonly stepIndex: number; readonly name: string; readonly state: string; } interface ParticipantOp { readonly plugin: string; readonly action: string; readonly kind: "do" | "compensate"; readonly at: number; } interface SagaEvent { readonly name: string; readonly payload: Record; readonly at: number; } test.describe("foundation-stack-saga — 3-step compensable workflow", () => { test.beforeEach(async ({ request }) => { const res = await request.post("/saga/reset"); expect(res.status()).toBe(200); }); test("1. happy path — all 3 steps complete; no compensation", async ({ request, }) => { const start = await request.post("/saga/start", { data: { triggerStep: "none" }, }); expect(start.status()).toBe(200); const { sagaId, state } = (await start.json()) as { sagaId: string; state: string; }; expect(typeof sagaId).toBe("string"); expect(state).toBe("completed"); const inst = await request.get(`/saga/${sagaId}`); expect(inst.status()).toBe(200); const body = (await inst.json()) as { instance: SagaInstance; steps: SagaStep[]; }; expect(body.instance.state).toBe("completed"); expect(body.instance.payload.sagaName).toBe("smoke-three-step"); expect(body.steps).toHaveLength(3); expect(body.steps.map((s) => s.state)).toEqual([ "completed", "completed", "completed", ]); const aLog = await request.get("/saga/participant/plugin-a/log"); const aBody = (await aLog.json()) as { ops: ParticipantOp[] }; const aDoActions = aBody.ops .filter((o) => o.kind === "do") .map((o) => o.action); expect(aDoActions).toEqual(["step-one", "step-three"]); expect(aBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0); const bLog = await request.get("/saga/participant/plugin-b/log"); const bBody = (await bLog.json()) as { ops: ParticipantOp[] }; expect( bBody.ops.filter((o) => o.kind === "do" && o.action === "step-two") ).toHaveLength(1); expect(bBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0); const events = await request.get(`/saga/${sagaId}/events`); const eBody = (await events.json()) as { events: SagaEvent[] }; const names = eBody.events.map((e) => e.name); expect(names).toContain("saga.instance.started"); expect(names).toContain("saga.instance.completed"); expect(names.filter((n) => n.includes("compensat"))).toHaveLength(0); expect(names.filter((n) => n === "saga.step.completed")).toHaveLength( 3 ); }); test("2. step-2 fails — reverse-order compensation across both plugins", async ({ request, }) => { const start = await request.post("/saga/start", { data: { triggerStep: "step-2" }, }); expect(start.status()).toBe(200); const { sagaId, state } = (await start.json()) as { sagaId: string; state: string; }; expect(state).toBe("compensated"); const inst = await request.get(`/saga/${sagaId}`); const body = (await inst.json()) as { instance: SagaInstance; steps: SagaStep[]; }; expect(body.instance.state).toBe("compensated"); expect(body.steps).toHaveLength(3); const byIndex: Record = {}; for (const s of body.steps) { byIndex[s.stepIndex] = s.state; } // Step 1 was completed then compensated; step 2 failed; step 3 // never ran. expect(byIndex[0]).toBe("compensated"); expect(byIndex[1]).toBe("failed"); expect(byIndex[2]).toBe("pending"); const aLog = await request.get("/saga/participant/plugin-a/log"); const aBody = (await aLog.json()) as { ops: ParticipantOp[] }; const aActions = aBody.ops.map((o) => `${o.action}:${o.kind}`); expect(aActions).toEqual(["step-one:do", "step-one:compensate"]); const bLog = await request.get("/saga/participant/plugin-b/log"); const bBody = (await bLog.json()) as { ops: ParticipantOp[] }; const bActions = bBody.ops.map((o) => `${o.action}:${o.kind}`); expect(bActions).toEqual(["step-two:do"]); const events = await request.get(`/saga/${sagaId}/events`); const eBody = (await events.json()) as { events: SagaEvent[] }; const names = eBody.events.map((e) => e.name); expect(names).toContain("saga.instance.started"); expect(names).toContain("saga.step.failed"); expect(names).toContain("saga.step.compensated"); expect(names).toContain("saga.instance.compensated"); expect(names.filter((n) => n === "saga.step.completed")).toHaveLength( 1 ); }); test("3. cross-plugin invocation proves SagaParticipantRegistry routes correctly", async ({ request, }) => { const start = await request.post("/saga/start", { data: { triggerStep: "none" }, }); const { sagaId } = (await start.json()) as { sagaId: string }; const aLog = await request.get("/saga/participant/plugin-a/log"); const aBody = (await aLog.json()) as { ops: ParticipantOp[] }; const bLog = await request.get("/saga/participant/plugin-b/log"); const bBody = (await bLog.json()) as { ops: ParticipantOp[] }; const interleaved = [...aBody.ops, ...bBody.ops].sort( (x, y) => x.at - y.at ); const trace = interleaved .filter((o) => o.kind === "do") .map((o) => `${o.plugin}/${o.action}`); expect(trace).toEqual([ "plugin-a/step-one", "plugin-b/step-two", "plugin-a/step-three", ]); const inst = await request.get(`/saga/${sagaId}`); const body = (await inst.json()) as { instance: SagaInstance }; expect(body.instance.state).toBe("completed"); }); });