Self-contained tsx script that exercises the pure state-machine +
dependency-engine logic against the user's exact 5-stage pipeline
shape (Inputfile → Internal Approval ⇉ Approved/Declined → Delivery)
— no DB needed.
Catches the regression from earlier today where `Cannot transition
from IN_PROGRESS to APPROVED` blocked the NONE-stage Mark Complete
shortcut, plus verifies:
- Order-based dependency fallback (when no V2 edges are drawn)
- Multi-branch unblock (approving Internal Approval opens BOTH
Approved and Declined simultaneously)
- Downstream-of-downstream stays correctly BLOCKED until its
specific parent completes (Delivery waits for Approved, not for
Internal Approval)
Run: npx tsx scripts/test-workflow-logic.ts
Non-zero exit on any failure. 19/19 assertions pass against current
HEAD.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 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,
|
|
} 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;
|
|
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(): Stage[] {
|
|
const stage = (
|
|
slug: string,
|
|
order: number,
|
|
deps: string[],
|
|
extra: Partial<Pick<Stage["template"], "isCriticalGate" | "isOptional">> = {}
|
|
): 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}`,
|
|
dependsOn: deps.map((d) => ({ prerequisiteId: `def_${d}` })),
|
|
},
|
|
});
|
|
|
|
return [
|
|
stage("inputfile", 1, []),
|
|
stage("internal-approval", 2, ["inputfile"], { isCriticalGate: true }),
|
|
stage("approved", 3, ["internal-approval"]),
|
|
stage("declined", 3, ["internal-approval"]),
|
|
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(", ")}`
|
|
);
|
|
}
|
|
|
|
// ─── 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);
|