test(dashboard): regression tests for stepwise builtin editor render path (foreach group + template children + rework edges)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 14:34:53 -07:00
parent 12560c4211
commit 021409d445

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "../workflow-flow-mapping";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
@@ -473,3 +474,110 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
expect(timeout.value).toBe("12000");
});
});
// ── Regression: selecting the real stepwise built-in renders the foreach group
// node (group type, NOT a plain default node) with its template children
// expanded (parentId set), and duplicating it preserves the template. ───────
/** The on-disk built-in stepwise workflow as the server serves it (the IR is the
* source of truth; the dashboard wraps it in a WorkflowDefinition). */
function builtinStepwiseDef(): WorkflowDefinition {
return {
id: "builtin:stepwise-coding",
name: "Stepwise coding (built-in)",
description: "",
ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
layout: {},
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
}
describe("WorkflowNodeEditor — built-in stepwise selection render path", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders the steps foreach as a group node (not a plain default) with its template children expanded", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinStepwiseDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// The built-in selection banner replaces the editing toolbar.
await screen.findByTestId("wf-readonly-banner");
// The `steps` foreach renders via the registered group component
// (ForeachGroupNode → data-testid wf-node-foreach), NOT React Flow's default
// node fallback. A default node would expose no wf-node-foreach testid.
const foreachGroup = await screen.findByTestId("wf-node-foreach");
expect(foreachGroup).toBeInTheDocument();
// parse-steps likewise renders via its registered component.
expect(await screen.findByTestId("wf-node-parse-steps")).toBeInTheDocument();
// The foreach template children (parentId-partitioned) are present in the
// canvas: the step-execute prompt and the per-step review node.
const flowNodeIds = [...document.querySelectorAll(".react-flow__node")].map((n) =>
n.getAttribute("data-id"),
);
expect(flowNodeIds).toContain(foreachChildFlowId("steps", "step-execute"));
expect(flowNodeIds).toContain(foreachChildFlowId("steps", "step-review"));
expect(flowNodeIds).toContain(foreachChildFlowId("steps", "step-done"));
});
it("irToFlow on the built-in stepwise IR yields a foreach group + rework-styled template edge (editor load path)", () => {
// Mirrors exactly what the editor's load effect feeds React Flow:
// const flow = irToFlow(activeWorkflow)
const { nodes, edges } = irToFlow(builtinStepwiseDef());
const group = nodes.find((n) => n.id === "steps");
expect(group?.type).toBe("foreach");
const children = nodes.filter((n) => n.parentId === "steps");
expect(children.map((c) => c.id).sort()).toEqual(
[
foreachChildFlowId("steps", "step-execute"),
foreachChildFlowId("steps", "step-review"),
foreachChildFlowId("steps", "step-done"),
].sort(),
);
// The intra-template rework edge (step-review → step-execute) renders with
// its rework styling so the editor shows the bounded loop-back.
const reworkEdges = edges.filter((e) => e.data?.kind === "rework");
expect(reworkEdges.length).toBeGreaterThan(0);
expect(reworkEdges.every((e) => e.animated === true && e.className === "wf-edge-rework")).toBe(true);
expect(reworkEdges.some((e) => e.source === foreachChildFlowId("steps", "step-review"))).toBe(true);
});
it("Duplicate-to-customize preserves the foreach template through the editor's save path", () => {
// "Duplicate to customize" copies the built-in IR verbatim into a new
// editable workflow; the user then saves, which round-trips through the
// editor's flowToIr on the exact nodes/edges irToFlow produced. Assert the
// template (incl. the rework edge) survives that round-trip.
const def = builtinStepwiseDef();
const { nodes, edges } = irToFlow(def);
const columns = def.ir.version === "v2" ? def.ir.columns : [];
const { ir: out } = flowToIr(def.name, nodes, edges, columns);
if (out.version !== "v2") throw new Error("expected v2");
const steps = out.nodes.find((n) => n.id === "steps");
expect(steps?.kind).toBe("foreach");
const template = steps!.config!.template as {
nodes: { id: string }[];
edges: { from: string; to: string; condition?: string; kind?: string }[];
};
expect(template.nodes.map((n) => n.id).sort()).toEqual(
["step-done", "step-execute", "step-review"].sort(),
);
// The two rework edges (revise/rethink → step-execute) survive with kind+condition.
const reworks = template.edges.filter((e) => e.kind === "rework");
expect(reworks.map((e) => e.condition).sort()).toEqual(
["outcome:revise", "outcome:rethink"].sort(),
);
expect(reworks.every((e) => e.from === "step-review" && e.to === "step-execute")).toBe(true);
// The approve edge routes to the template exit and is NOT a rework edge.
const approve = template.edges.find((e) => e.condition === "outcome:approve");
expect(approve).toMatchObject({ from: "step-review", to: "step-done" });
expect(approve?.kind).toBeUndefined();
});
});