dow-prod-tracker/scripts/test-workflow-logic.ts
DJP 1de8985507 Outcome-branch routing: only one path fires per approval decision
Adds PipelineBranchKind (NONE/APPROVED/DECLINED) on stage definitions
so producers can tag the two routes downstream of a FORMAL-approval
stage. The engine then picks exactly one branch per decision:

  • Approve → APPROVED-branch children auto-open, DECLINED-branch
    siblings auto-SKIPPED (grayed out, unreachable)
  • Request Changes → DECLINED-branch children auto-stamped APPROVED
    (passive record of the decline), APPROVED-branch siblings auto-
    SKIPPED, then the existing rework edge fires as before

Also fixes a quiet bug in pipeline-template-service.addStage where
approvalType was being dropped from new stages (whitelist didn't
include it).

UI: dropdown on the stage edit sheet + branch-kind badges on the
deliverable detail page. SKIPPED rendering already grays things out.

Smoke test extended: 65/65 passing including the user's split-on-
decision case, N-way split, regression assertion that untagged
pipelines still open all children, and an idempotency check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:16:22 -04:00

853 lines
27 KiB
TypeScript

// Self-contained workflow smoke test. No DB needed — exercises the pure
// state machine + dependency engine against the user's 5-stage pipeline:
//
// Inputfile (NONE) → Internal Approval (FORMAL, gate)
// ├─ Approved
// │ └─ Delivery
// └─ Declined (rework edge → Inputfile)
//
// Run: npx tsx scripts/test-workflow-logic.ts
// Each step prints PASS / FAIL; non-zero exit on any failure.
import { canTransition } from "@/lib/pipeline/stage-machine";
import {
canStageStart,
getStageIdsToUnblock,
getBranchStagesToSkipOnApprove,
getBranchOutcomeOnReject,
} from "@/lib/pipeline/dependency-engine";
type StageStatus =
| "BLOCKED"
| "NOT_STARTED"
| "IN_PROGRESS"
| "IN_REVIEW"
| "CHANGES_REQUESTED"
| "APPROVED"
| "DELIVERED"
| "SKIPPED";
interface Stage {
id: string;
status: StageStatus;
order: number;
template: {
id: string;
slug: string;
isCriticalGate: boolean;
isOptional: boolean;
dependsOn: { prerequisiteId: string }[];
};
stageDefinition: {
id: string;
branchKind?: "NONE" | "APPROVED" | "DECLINED" | null;
dependsOn: { prerequisiteId: string }[];
} | null;
}
// ─── Pipeline builder ─────────────────────────────────────────────────────
// Mirrors the user's pipeline graph from the screenshot. Forward edges
// are encoded in stageDefinition.dependsOn (V2 dynamic). Rework edges
// (Declined → Inputfile, Delivery → Inputfile) aren't exercised by the
// engine — those live in PipelineStageRework and route through
// stage-transition-service.applyRework, which we test separately.
function buildPipeline(opts: { tagBranches?: boolean } = {}): Stage[] {
const stage = (
slug: string,
order: number,
deps: string[],
extra: Partial<Pick<Stage["template"], "isCriticalGate" | "isOptional">> & {
branchKind?: "NONE" | "APPROVED" | "DECLINED";
} = {}
): Stage => ({
id: `ds_${slug}`,
status: "BLOCKED",
order,
template: {
id: `tpl_${slug}`,
slug,
isCriticalGate: extra.isCriticalGate ?? false,
isOptional: extra.isOptional ?? false,
dependsOn: [],
},
stageDefinition: {
id: `def_${slug}`,
branchKind: extra.branchKind ?? "NONE",
dependsOn: deps.map((d) => ({ prerequisiteId: `def_${d}` })),
},
});
return [
stage("inputfile", 1, []),
stage("internal-approval", 2, ["inputfile"], { isCriticalGate: true }),
stage("approved", 3, ["internal-approval"], {
branchKind: opts.tagBranches ? "APPROVED" : "NONE",
}),
stage("declined", 3, ["internal-approval"], {
branchKind: opts.tagBranches ? "DECLINED" : "NONE",
}),
stage("delivery", 4, ["approved"]),
];
}
// ─── Test harness ─────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
const failures: string[] = [];
function assert(name: string, cond: boolean, detail?: string) {
if (cond) {
passed++;
console.log(`${name}`);
} else {
failed++;
failures.push(`${name}${detail ? `${detail}` : ""}`);
console.log(`${name}${detail ? `${detail}` : ""}`);
}
}
function section(name: string) {
console.log(`\n— ${name}`);
}
// Simulate the auto-advance + canStageStart flow that stage-service
// would execute end-to-end on the user's pipeline.
function simulateTerminalTransition(
stages: Stage[],
stageId: string,
newStatus: StageStatus
): Stage[] {
const updated = stages.map((s) =>
s.id === stageId ? { ...s, status: newStatus } : s
);
const just = updated.find((s) => s.id === stageId)!;
const unblockIds = getStageIdsToUnblock(just, updated);
return updated.map((s) =>
unblockIds.includes(s.id) ? { ...s, status: "IN_PROGRESS" } : s
);
}
// ─── Run ──────────────────────────────────────────────────────────────────
section("State machine — NONE stage Mark Complete shortcut");
{
const r = canTransition("IN_PROGRESS", "APPROVED");
assert(
"IN_PROGRESS → APPROVED is allowed (Mark Complete on NONE stage)",
r.allowed,
r.reason
);
}
{
const r = canTransition("IN_PROGRESS", "IN_REVIEW");
assert(
"IN_PROGRESS → IN_REVIEW still allowed (FORMAL/SIMPLE flow)",
r.allowed
);
}
{
const r = canTransition("IN_REVIEW", "APPROVED");
assert("IN_REVIEW → APPROVED allowed (FORMAL approve)", r.allowed);
}
{
const r = canTransition("IN_REVIEW", "CHANGES_REQUESTED");
assert("IN_REVIEW → CHANGES_REQUESTED allowed (request changes)", r.allowed);
}
{
const r = canTransition("BLOCKED", "APPROVED");
assert(
"BLOCKED → APPROVED rejected (must finish prereq first)",
!r.allowed
);
}
section("Dependency engine — order-based deps drive the cascade");
let p = buildPipeline();
// Initial state: producer hasn't done anything yet. Inputfile is the only
// stage without prereqs.
{
const inputfile = p.find((s) => s.template.slug === "inputfile")!;
assert(
"Inputfile has no prerequisites (it's stage 1)",
inputfile.stageDefinition!.dependsOn.length === 0
);
// Producer manually starts it → IN_PROGRESS
p = p.map((s) =>
s.id === inputfile.id ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
assert("Producer clicks Start Work → Inputfile IN_PROGRESS", true);
}
// Mark Complete on Inputfile → IN_PROGRESS → APPROVED → triggers cascade
section("Mark Complete on Inputfile (the failing case from the screenshot)");
{
const inputfile = p.find((s) => s.template.slug === "inputfile")!;
const r = canTransition(inputfile.status, "APPROVED");
assert(
"Mark Complete (IN_PROGRESS → APPROVED) is now allowed",
r.allowed,
r.reason
);
p = simulateTerminalTransition(p, inputfile.id, "APPROVED");
const ia = p.find((s) => s.template.slug === "internal-approval")!;
assert(
"Internal Approval auto-advances to IN_PROGRESS",
ia.status === "IN_PROGRESS",
`actual: ${ia.status}`
);
// Downstream-of-downstream stays BLOCKED at this point.
const approved = p.find((s) => s.template.slug === "approved")!;
const declined = p.find((s) => s.template.slug === "declined")!;
assert(
"Approved stays BLOCKED (waiting on Internal Approval)",
approved.status === "BLOCKED"
);
assert(
"Declined stays BLOCKED (waiting on Internal Approval)",
declined.status === "BLOCKED"
);
}
section("Approve & advance on Internal Approval (FORMAL stage)");
{
const ia = p.find((s) => s.template.slug === "internal-approval")!;
// FORMAL stage goes IN_PROGRESS → IN_REVIEW (Submit) → APPROVED. We
// simulate the final APPROVED step the StageReviewPanel triggers.
p = p.map((s) =>
s.id === ia.id ? { ...s, status: "IN_REVIEW" as StageStatus } : s
);
assert(
"IN_PROGRESS → IN_REVIEW (Submit for review) allowed",
canTransition("IN_PROGRESS", "IN_REVIEW").allowed
);
// Now the reviewer clicks Approve & advance.
p = simulateTerminalTransition(p, ia.id, "APPROVED");
// Both downstream branches (Approved + Declined) depend on Internal
// Approval — both should unblock.
const approved = p.find((s) => s.template.slug === "approved")!;
const declined = p.find((s) => s.template.slug === "declined")!;
assert(
"Approved auto-advances to IN_PROGRESS",
approved.status === "IN_PROGRESS",
`actual: ${approved.status}`
);
assert(
"Declined ALSO auto-advances to IN_PROGRESS (shared prereq)",
declined.status === "IN_PROGRESS",
`actual: ${declined.status}`
);
// Delivery should still be BLOCKED — it depends on Approved, not on
// Internal Approval.
const delivery = p.find((s) => s.template.slug === "delivery")!;
assert(
"Delivery still BLOCKED (depends on Approved, not Internal Approval)",
delivery.status === "BLOCKED",
`actual: ${delivery.status}`
);
}
section("Producer marks Approved complete → Delivery cascades");
{
const approved = p.find((s) => s.template.slug === "approved")!;
p = simulateTerminalTransition(p, approved.id, "APPROVED");
const delivery = p.find((s) => s.template.slug === "delivery")!;
assert(
"Delivery auto-advances to IN_PROGRESS once Approved completes",
delivery.status === "IN_PROGRESS",
`actual: ${delivery.status}`
);
}
section("Edge: canStageStart guards work via the engine");
{
// Reset to fresh pipeline so we can ask "can Delivery start right now?"
// before any prereqs are met.
const fresh = buildPipeline();
const delivery = fresh.find((s) => s.template.slug === "delivery")!;
const r = canStageStart(delivery, fresh);
assert(
"Fresh Delivery: cannot start — Approved is not terminal",
!r.allowed,
r.reason
);
}
{
const fresh = buildPipeline();
const inputfile = fresh.find((s) => s.template.slug === "inputfile")!;
const r = canStageStart(inputfile, fresh);
assert(
"Fresh Inputfile: can start — no prerequisites",
r.allowed,
r.reason
);
}
section("Edge: order-fallback deps when no V2 edges are drawn");
{
// Build a pipeline with NO stageDefinition deps to verify the
// order-fallback path. (Producers building a quick pipeline who don't
// draw forward arrows still get a sensible left-to-right cascade.)
const fallback = buildPipeline().map((s) => ({
...s,
stageDefinition: { id: s.stageDefinition!.id, dependsOn: [] },
}));
const inputfile = fallback.find((s) => s.template.slug === "inputfile")!;
const ia = fallback.find((s) => s.template.slug === "internal-approval")!;
inputfile.status = "IN_PROGRESS";
const unblock = getStageIdsToUnblock(
{ ...inputfile, status: "APPROVED" },
fallback.map((s) => (s.id === inputfile.id ? { ...s, status: "APPROVED" as StageStatus } : s))
);
assert(
"No V2 deps: order fallback unblocks the next-order stage",
unblock.includes(ia.id),
`unblock = ${unblock.join(", ")}`
);
}
// ─── Generic shape coverage ──────────────────────────────────────────────
// The engine must work for any pipeline shape, not just the user's
// canonical 5-stage flow. These build minimal pipelines for each shape
// pattern and verify the cascade behaves correctly.
function pipelineFromGraph(
edges: Array<[from: string, to: string]>,
nodeOrder?: Record<string, number>
): Stage[] {
const nodes = new Set<string>();
edges.forEach(([a, b]) => {
nodes.add(a);
nodes.add(b);
});
const byName = new Map<string, number>();
Array.from(nodes).forEach((n, i) => byName.set(n, nodeOrder?.[n] ?? i + 1));
return Array.from(nodes).map((n) => ({
id: `ds_${n}`,
status: "BLOCKED" as StageStatus,
order: byName.get(n)!,
template: {
id: `tpl_${n}`,
slug: n,
isCriticalGate: false,
isOptional: false,
dependsOn: [],
},
stageDefinition: {
id: `def_${n}`,
dependsOn: edges
.filter(([, to]) => to === n)
.map(([from]) => ({ prerequisiteId: `def_${from}` })),
},
}));
}
function complete(stages: Stage[], slug: string): Stage[] {
const stage = stages.find((s) => s.template.slug === slug);
if (!stage) throw new Error(`No stage ${slug}`);
return simulateTerminalTransition(stages, stage.id, "APPROVED");
}
function statusOf(stages: Stage[], slug: string): StageStatus {
return stages.find((s) => s.template.slug === slug)!.status;
}
section("Shape: linear A → B → C → D");
{
let g = pipelineFromGraph([
["A", "B"],
["B", "C"],
["C", "D"],
]);
// Producer manually starts the head.
g = g.map((s) =>
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
g = complete(g, "A");
assert("Linear: A done → B opens", statusOf(g, "B") === "IN_PROGRESS");
assert("Linear: C stays BLOCKED", statusOf(g, "C") === "BLOCKED");
assert("Linear: D stays BLOCKED", statusOf(g, "D") === "BLOCKED");
g = complete(g, "B");
assert("Linear: B done → C opens", statusOf(g, "C") === "IN_PROGRESS");
assert("Linear: D still BLOCKED", statusOf(g, "D") === "BLOCKED");
g = complete(g, "C");
assert("Linear: C done → D opens", statusOf(g, "D") === "IN_PROGRESS");
}
section("Shape: fan-out A → {B, C, D}");
{
let g = pipelineFromGraph([
["A", "B"],
["A", "C"],
["A", "D"],
]);
g = g.map((s) =>
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
g = complete(g, "A");
assert("Fan-out: B opens", statusOf(g, "B") === "IN_PROGRESS");
assert("Fan-out: C opens", statusOf(g, "C") === "IN_PROGRESS");
assert("Fan-out: D opens", statusOf(g, "D") === "IN_PROGRESS");
}
section("Shape: fan-in {A, B, C} → D");
{
let g = pipelineFromGraph(
[
["A", "D"],
["B", "D"],
["C", "D"],
],
{ A: 1, B: 1, C: 1, D: 2 }
);
g = g.map((s) =>
["A", "B", "C"].includes(s.template.slug)
? { ...s, status: "IN_PROGRESS" as StageStatus }
: s
);
g = complete(g, "A");
assert("Fan-in: D BLOCKED with only A done", statusOf(g, "D") === "BLOCKED");
g = complete(g, "B");
assert("Fan-in: D BLOCKED with A+B done", statusOf(g, "D") === "BLOCKED");
g = complete(g, "C");
assert(
"Fan-in: D opens only after all three predecessors done",
statusOf(g, "D") === "IN_PROGRESS"
);
}
section("Shape: diamond A → {B, C} → D");
{
let g = pipelineFromGraph(
[
["A", "B"],
["A", "C"],
["B", "D"],
["C", "D"],
],
{ A: 1, B: 2, C: 2, D: 3 }
);
g = g.map((s) =>
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
g = complete(g, "A");
assert("Diamond: A done → B opens", statusOf(g, "B") === "IN_PROGRESS");
assert("Diamond: A done → C opens", statusOf(g, "C") === "IN_PROGRESS");
assert("Diamond: D still BLOCKED", statusOf(g, "D") === "BLOCKED");
g = complete(g, "B");
assert(
"Diamond: only B done → D still BLOCKED (also waits for C)",
statusOf(g, "D") === "BLOCKED"
);
g = complete(g, "C");
assert(
"Diamond: B+C done → D opens",
statusOf(g, "D") === "IN_PROGRESS"
);
}
section("Shape: long chain (10 stages)");
{
const slugs = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"];
const edges: Array<[string, string]> = [];
for (let i = 0; i < slugs.length - 1; i++) {
edges.push([slugs[i], slugs[i + 1]]);
}
let g = pipelineFromGraph(edges);
g = g.map((s) =>
s.template.slug === "s1" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
for (let i = 0; i < slugs.length - 1; i++) {
g = complete(g, slugs[i]);
assert(
`Chain step ${i + 1}: ${slugs[i + 1]} opens after ${slugs[i]} completes`,
statusOf(g, slugs[i + 1]) === "IN_PROGRESS"
);
}
}
// ─── Rework reset planner (mirrors applyRework's where-clause logic) ─────
// applyRework resets stages where `order > targetOrder && order <=
// currentOrder && status NOT IN (SKIPPED, NOT_STARTED)` to NOT_STARTED.
// This pure function reproduces that math so we can verify it from
// arbitrary pipeline + rework configurations without spinning up a DB.
function planReworkReset(
stages: Stage[],
sourceSlug: string,
targetSlug: string
): string[] {
const source = stages.find((s) => s.template.slug === sourceSlug)!;
const target = stages.find((s) => s.template.slug === targetSlug)!;
if (target.order >= source.order) {
throw new Error("Target must have lower order than source for rework");
}
return stages
.filter(
(s) =>
s.order > target.order &&
s.order <= source.order &&
s.status !== "SKIPPED" &&
s.status !== "NOT_STARTED"
)
.map((s) => s.template.slug)
.sort();
}
section("Rework: linear pipeline, rework from far end back to head");
{
let g = pipelineFromGraph([
["A", "B"],
["B", "C"],
["C", "D"],
]);
// Walk the whole pipeline to APPROVED, then simulate rework D → A.
g = g.map((s) =>
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
);
g = complete(g, "A");
g = complete(g, "B");
g = complete(g, "C");
g = complete(g, "D");
assert("Rework setup: all four stages APPROVED", statusOf(g, "A") === "APPROVED" && statusOf(g, "D") === "APPROVED");
const toReset = planReworkReset(g, "D", "A");
assert(
"Rework D → A: B, C, D reset; A untouched",
toReset.join(",") === "B,C,D",
`actual: ${toReset.join(",")}`
);
}
section("Rework: SKIPPED stages stay SKIPPED across rework");
{
let g = pipelineFromGraph([
["A", "B"],
["B", "C"],
["C", "D"],
]);
// Simulate: A done, B skipped, C done, D done.
g = g.map((s) => {
if (s.template.slug === "A") return { ...s, status: "APPROVED" as StageStatus };
if (s.template.slug === "B") return { ...s, status: "SKIPPED" as StageStatus };
if (s.template.slug === "C") return { ...s, status: "APPROVED" as StageStatus };
if (s.template.slug === "D") return { ...s, status: "APPROVED" as StageStatus };
return s;
});
const toReset = planReworkReset(g, "D", "A");
assert(
"Rework D → A skipping B: only C, D reset; B stays SKIPPED",
toReset.join(",") === "C,D",
`actual: ${toReset.join(",")}`
);
}
section("Rework: short rework D → C only resets D");
{
let g = pipelineFromGraph([
["A", "B"],
["B", "C"],
["C", "D"],
]);
// All four APPROVED, then short rework from D back to C.
g = g.map((s) => ({ ...s, status: "APPROVED" as StageStatus }));
const toReset = planReworkReset(g, "D", "C");
assert(
"Rework D → C: only D resets (single-step back)",
toReset.join(",") === "D",
`actual: ${toReset.join(",")}`
);
}
section("Rework: user's pipeline — Declined → Inputfile resets everything between");
{
// Match the user's screenshot exactly.
const stages: Stage[] = [
{ name: "inputfile", order: 1 },
{ name: "internal-approval", order: 2 },
{ name: "approved", order: 3 },
{ name: "declined", order: 3 },
{ name: "delivery", order: 4 },
].map(({ name, order }) => ({
id: `ds_${name}`,
status: "APPROVED" as StageStatus, // pretend they've all completed
order,
template: {
id: `tpl_${name}`,
slug: name,
isCriticalGate: name === "internal-approval",
isOptional: false,
dependsOn: [],
},
stageDefinition: { id: `def_${name}`, dependsOn: [] },
}));
const toReset = planReworkReset(stages, "declined", "inputfile");
assert(
"User pipeline rework Declined → Inputfile: internal-approval + approved + declined all reset; inputfile spared",
!toReset.includes("inputfile") &&
toReset.includes("internal-approval") &&
toReset.includes("declined"),
`actual: ${toReset.join(",")}`
);
}
// ─── Outcome-branch routing ───────────────────────────────────────────────
// User's pipeline with branch tags: Approved+Declined are downstream of
// the FORMAL parent, but only one fires per decision. The other goes
// SKIPPED (visually grayed out, blocked from use).
section("Branch routing — APPROVE picks the Approved path, skips Declined");
{
// Setup: parent (internal-approval) just hit APPROVED.
let pipeline = buildPipeline({ tagBranches: true });
pipeline = pipeline.map((s) =>
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
);
pipeline = pipeline.map((s) =>
s.template.slug === "internal-approval"
? { ...s, status: "APPROVED" as StageStatus }
: s
);
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
const unblock = getStageIdsToUnblock(parent, pipeline);
const skip = getBranchStagesToSkipOnApprove(parent, pipeline);
// Only Approved branch opens; Declined branch goes SKIPPED.
const unblockSlugs = pipeline
.filter((s) => unblock.includes(s.id))
.map((s) => s.template.slug)
.sort();
const skipSlugs = pipeline
.filter((s) => skip.includes(s.id))
.map((s) => s.template.slug)
.sort();
assert(
"APPROVED path: only 'approved' branch unblocks",
unblockSlugs.length === 1 && unblockSlugs[0] === "approved",
`unblock=[${unblockSlugs.join(",")}]`
);
assert(
"APPROVED path: 'declined' branch is auto-SKIPPED",
skipSlugs.length === 1 && skipSlugs[0] === "declined",
`skip=[${skipSlugs.join(",")}]`
);
// Sanity: without branch tags, BOTH children would unblock (old behaviour).
const untaggedPipeline = buildPipeline({ tagBranches: false }).map((s) =>
s.template.slug === "inputfile" || s.template.slug === "internal-approval"
? { ...s, status: "APPROVED" as StageStatus }
: s
);
const untaggedParent = untaggedPipeline.find(
(s) => s.template.slug === "internal-approval"
)!;
const untaggedUnblock = getStageIdsToUnblock(
untaggedParent,
untaggedPipeline
).map((id) => untaggedPipeline.find((s) => s.id === id)!.template.slug);
assert(
"Untagged pipeline opens BOTH children (regression check)",
untaggedUnblock.includes("approved") &&
untaggedUnblock.includes("declined"),
`untaggedUnblock=[${untaggedUnblock.sort().join(",")}]`
);
}
section("Branch routing — REJECT stamps Declined, skips Approved");
{
let pipeline = buildPipeline({ tagBranches: true });
pipeline = pipeline.map((s) =>
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
);
// Parent is in flight — Request Changes about to fire.
pipeline = pipeline.map((s) =>
s.template.slug === "internal-approval"
? { ...s, status: "IN_REVIEW" as StageStatus }
: s
);
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
const { idsToStampApproved, idsToSkip } = getBranchOutcomeOnReject(
parent,
pipeline
);
const stampedSlugs = pipeline
.filter((s) => idsToStampApproved.includes(s.id))
.map((s) => s.template.slug)
.sort();
const skippedSlugs = pipeline
.filter((s) => idsToSkip.includes(s.id))
.map((s) => s.template.slug)
.sort();
assert(
"REJECT path: 'declined' branch is auto-stamped APPROVED",
stampedSlugs.length === 1 && stampedSlugs[0] === "declined",
`stamped=[${stampedSlugs.join(",")}]`
);
assert(
"REJECT path: 'approved' branch is auto-SKIPPED",
skippedSlugs.length === 1 && skippedSlugs[0] === "approved",
`skipped=[${skippedSlugs.join(",")}]`
);
}
section("Branch routing — generalizes to N-way splits");
{
// 3-way split: parent → { approved-a, approved-b, declined }.
// Two APPROVED branches both open on approve; one DECLINED is skipped.
const stages: Stage[] = [
{
id: "ds_parent",
status: "APPROVED",
order: 1,
template: {
id: "tpl_parent",
slug: "parent",
isCriticalGate: true,
isOptional: false,
dependsOn: [],
},
stageDefinition: {
id: "def_parent",
branchKind: "NONE",
dependsOn: [],
},
},
...["approved-a", "approved-b"].map<Stage>((slug) => ({
id: `ds_${slug}`,
status: "BLOCKED",
order: 2,
template: {
id: `tpl_${slug}`,
slug,
isCriticalGate: false,
isOptional: false,
dependsOn: [],
},
stageDefinition: {
id: `def_${slug}`,
branchKind: "APPROVED",
dependsOn: [{ prerequisiteId: "def_parent" }],
},
})),
{
id: "ds_declined",
status: "BLOCKED",
order: 2,
template: {
id: "tpl_declined",
slug: "declined",
isCriticalGate: false,
isOptional: false,
dependsOn: [],
},
stageDefinition: {
id: "def_declined",
branchKind: "DECLINED",
dependsOn: [{ prerequisiteId: "def_parent" }],
},
},
];
const parent = stages.find((s) => s.template.slug === "parent")!;
const unblock = getStageIdsToUnblock(parent, stages)
.map((id) => stages.find((s) => s.id === id)!.template.slug)
.sort();
const skip = getBranchStagesToSkipOnApprove(parent, stages)
.map((id) => stages.find((s) => s.id === id)!.template.slug)
.sort();
assert(
"3-way: BOTH APPROVED branches open on approve",
unblock.length === 2 &&
unblock.includes("approved-a") &&
unblock.includes("approved-b"),
`unblock=[${unblock.join(",")}]`
);
assert(
"3-way: DECLINED branch is skipped on approve",
skip.length === 1 && skip[0] === "declined",
`skip=[${skip.join(",")}]`
);
}
section("Branch routing — already-skipped branches are idempotent");
{
let pipeline = buildPipeline({ tagBranches: true });
pipeline = pipeline.map((s) =>
s.template.slug === "declined" ? { ...s, status: "SKIPPED" as StageStatus } : s
);
pipeline = pipeline.map((s) =>
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
);
pipeline = pipeline.map((s) =>
s.template.slug === "internal-approval"
? { ...s, status: "APPROVED" as StageStatus }
: s
);
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
const skip = getBranchStagesToSkipOnApprove(parent, pipeline);
assert(
"Already-SKIPPED branch is not re-targeted",
skip.length === 0,
`skip=[${skip.join(",")}]`
);
}
section("State machine — additional shortcuts");
{
// Shortcuts added in 46cd8f8 to declutter producer workflow.
for (const [from, to, expect] of [
["IN_PROGRESS", "SKIPPED", true],
["CHANGES_REQUESTED", "APPROVED", true],
["BLOCKED", "IN_PROGRESS", true], // when prereqs are now satisfied
["DELIVERED", "APPROVED", true], // downgrade if mistakenly delivered
["SKIPPED", "IN_PROGRESS", true], // unskip directly
["BLOCKED", "APPROVED", false], // still blocked — can't skip work
["NOT_STARTED", "APPROVED", false], // must start first
] as const) {
const r = canTransition(from, to);
assert(
`${from}${to} is ${expect ? "allowed" : "rejected"}`,
r.allowed === expect,
r.reason
);
}
}
// ─── Summary ──────────────────────────────────────────────────────────────
console.log("\n──────────────────────────────────────────────────");
console.log(
`Result: ${passed} passed, ${failed} failed (${passed + failed} total)`
);
if (failed > 0) {
console.log("\nFailures:");
failures.forEach((f) => console.log(`${f}`));
process.exit(1);
}
console.log("✓ all workflow logic assertions passed");
process.exit(0);