236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
/**
|
|
* U12 (KTD-12) — parse-steps node handler, parser registry resolution, pin
|
|
* protection, and plugin-parser fail-closed posture.
|
|
*/
|
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import type { TaskDetail, TaskStep, WorkflowIr } from "@fusion/core";
|
|
import { getStepParserRegistry, __resetStepParserRegistryForTests } from "@fusion/core";
|
|
|
|
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
|
import { createNoopLegacySeams, type ParseStepsHandlerDeps } from "../workflow-node-handlers.js";
|
|
import {
|
|
registerPluginStepParsers,
|
|
unregisterPluginStepParsers,
|
|
} from "../plugin-parser-adapter.js";
|
|
|
|
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
|
|
|
function task(): TaskDetail {
|
|
return { id: "FN-PARSE", title: "t", steps: [] as TaskStep[] } as unknown as TaskDetail;
|
|
}
|
|
|
|
/** start → parse → end, with optional outcome edges off the parse node. */
|
|
function parseIr(parser: string, artifact?: string, parseEdges?: WorkflowIr["edges"], extraNodes: WorkflowIr["nodes"] = []): WorkflowIr {
|
|
return {
|
|
version: "v2",
|
|
name: "parse-test",
|
|
columns: [{ id: "work", name: "Work", traits: [] }],
|
|
artifacts: artifact && artifact !== "PROMPT.md" ? [{ key: artifact }] : undefined,
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "parse", kind: "parse-steps", config: { artifact: artifact ?? "PROMPT.md", parser } },
|
|
{ id: "end", kind: "end" },
|
|
...extraNodes,
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "parse" },
|
|
{ from: "parse", to: "end", condition: "success" },
|
|
...(parseEdges ?? []),
|
|
],
|
|
} as WorkflowIr;
|
|
}
|
|
|
|
function makeDeps(over: Partial<ParseStepsHandlerDeps> = {}): {
|
|
deps: ParseStepsHandlerDeps;
|
|
written: TaskStep[][];
|
|
audits: Array<{ reason: string; detail: string }>;
|
|
} {
|
|
const written: TaskStep[][] = [];
|
|
const audits: Array<{ reason: string; detail: string }> = [];
|
|
const deps: ParseStepsHandlerDeps = {
|
|
readArtifact: async () => "### Step 1: do a\n### Step 2: do b",
|
|
writeSteps: async (_t, steps) => {
|
|
written.push(steps);
|
|
},
|
|
audit: (reason, detail) => audits.push({ reason, detail }),
|
|
...over,
|
|
};
|
|
return { deps, written, audits };
|
|
}
|
|
|
|
async function runParse(ir: WorkflowIr, deps: ParseStepsHandlerDeps) {
|
|
const exec = new WorkflowGraphExecutor({ seams: createNoopLegacySeams(), parseStepsDeps: deps });
|
|
return exec.run(task(), settingsOn(), ir);
|
|
}
|
|
|
|
describe("parse-steps node handler (U12, KTD-12)", () => {
|
|
beforeEach(() => {
|
|
__resetStepParserRegistryForTests();
|
|
});
|
|
|
|
it("registry resolution: step-headings parses and writes steps with statuses pending", async () => {
|
|
const { deps, written } = makeDeps();
|
|
const result = await runParse(parseIr("step-headings"), deps);
|
|
expect(result.outcome).toBe("success");
|
|
expect(written).toHaveLength(1);
|
|
expect(written[0]).toEqual([
|
|
{ name: "do a", status: "pending" },
|
|
{ name: "do b", status: "pending" },
|
|
]);
|
|
});
|
|
|
|
it("preserves dependsOn from the headings (depends:) annotation", async () => {
|
|
const { deps, written } = makeDeps({
|
|
readArtifact: async () => "### Step 1: a\n### Step 2 (depends: 1): b",
|
|
});
|
|
const result = await runParse(parseIr("step-headings"), deps);
|
|
expect(result.outcome).toBe("success");
|
|
expect(written[0]).toEqual([
|
|
{ name: "a", status: "pending" },
|
|
{ name: "b", status: "pending", dependsOn: [0] },
|
|
]);
|
|
});
|
|
|
|
it("json-steps parser writes structured steps", async () => {
|
|
const { deps, written } = makeDeps({
|
|
readArtifact: async () => JSON.stringify([{ name: "x" }, { name: "y", depends: [1] }]),
|
|
});
|
|
const result = await runParse(parseIr("json-steps"), deps);
|
|
expect(result.outcome).toBe("success");
|
|
expect(written[0]).toEqual([
|
|
{ name: "x", status: "pending" },
|
|
{ name: "y", status: "pending", dependsOn: [0] },
|
|
]);
|
|
});
|
|
|
|
it("unknown parser → parse-error (audited), no write", async () => {
|
|
const { deps, written, audits } = makeDeps();
|
|
// Route outcome:parse-error so the run does not just propagate failure off end.
|
|
const ir = parseIr("does-not-exist", undefined, [
|
|
{ from: "parse", to: "end", condition: "outcome:parse-error" },
|
|
]);
|
|
const result = await runParse(ir, deps);
|
|
// The parse node fails; with the parse-error edge routed to end, the run
|
|
// surfaces the parse node's own failure outcome.
|
|
expect(written).toHaveLength(0);
|
|
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
|
|
expect(result.context["node:parse:value"]).toBe("parse-error");
|
|
});
|
|
|
|
it("parser throw (malformed artifact) → parse-error, never crashes", async () => {
|
|
const { deps, audits } = makeDeps({
|
|
readArtifact: async () => "not json at all",
|
|
});
|
|
const result = await runParse(parseIr("json-steps"), deps);
|
|
expect(result.executed).toBe(true);
|
|
expect(result.context["node:parse:value"]).toBe("parse-error");
|
|
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
|
|
});
|
|
|
|
it("missing artifact (undefined content) → parse-error", async () => {
|
|
const { deps, audits } = makeDeps({ readArtifact: async () => undefined });
|
|
const result = await runParse(parseIr("step-headings"), deps);
|
|
expect(result.context["node:parse:value"]).toBe("parse-error");
|
|
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
|
|
});
|
|
|
|
it("clean empty parse → no-steps outcome (success), writes empty list", async () => {
|
|
const { deps, written } = makeDeps({ readArtifact: async () => "no headings here" });
|
|
const ir = parseIr("step-headings", undefined, [
|
|
{ from: "parse", to: "end", condition: "outcome:no-steps" },
|
|
]);
|
|
const result = await runParse(ir, deps);
|
|
expect(result.outcome).toBe("success");
|
|
expect(result.context["node:parse:value"]).toBe("no-steps");
|
|
expect(written).toEqual([[]]);
|
|
});
|
|
|
|
it("pin protection: parse after a foreach expanded → pin-mismatch failure, no write", async () => {
|
|
const { deps, written, audits } = makeDeps({
|
|
hasExpandedForeach: async () => true,
|
|
});
|
|
const ir = parseIr("step-headings", undefined, [
|
|
{ from: "parse", to: "end", condition: "outcome:pin-mismatch" },
|
|
]);
|
|
const result = await runParse(ir, deps);
|
|
expect(written).toHaveLength(0);
|
|
expect(result.context["node:parse:value"]).toBe("pin-mismatch");
|
|
expect(audits.some((a) => a.reason === "pin-mismatch")).toBe(true);
|
|
});
|
|
|
|
it("default workflow parity: registry step-headings == direct parseStepHeadings call", async () => {
|
|
const { parseStepHeadings } = await import("@fusion/core");
|
|
const content = "### Step 1: alpha\n### Step 2 (depends: 1): beta";
|
|
const direct = parseStepHeadings(content);
|
|
const viaRegistry = getStepParserRegistry().getParser("step-headings")!.parse(content);
|
|
expect(viaRegistry.steps.map((s) => ({ name: s.name, dependsOn: s.dependsOn }))).toEqual(
|
|
direct.map((s) => ({ name: s.name, dependsOn: s.dependsOn })),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("plugin step-parser fail-closed (U12, KTD-12)", () => {
|
|
beforeEach(() => {
|
|
__resetStepParserRegistryForTests();
|
|
});
|
|
|
|
it("happy path: a registered plugin parser resolves and writes steps", async () => {
|
|
registerPluginStepParsers({
|
|
pluginId: "acme",
|
|
contributions: [{ parserId: "yaml", parse: () => ({ steps: [{ name: "from-plugin" }] }) }],
|
|
});
|
|
const { deps, written } = makeDeps({ readArtifact: async () => "ignored" });
|
|
const result = await runParse(parseIr("plugin:acme:yaml"), deps);
|
|
expect(result.outcome).toBe("success");
|
|
expect(written[0]).toEqual([{ name: "from-plugin", status: "pending" }]);
|
|
unregisterPluginStepParsers("acme", ["yaml"]);
|
|
});
|
|
|
|
it("a throwing plugin parser maps to parse-error (fail-closed, audited), never crashes", async () => {
|
|
registerPluginStepParsers({
|
|
pluginId: "acme",
|
|
contributions: [
|
|
{
|
|
parserId: "boom",
|
|
parse: () => {
|
|
throw new Error("kaboom");
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const { deps, audits } = makeDeps({ readArtifact: async () => "x" });
|
|
const ir = parseIr("plugin:acme:boom", undefined, [
|
|
{ from: "parse", to: "end", condition: "outcome:parse-error" },
|
|
]);
|
|
const result = await runParse(ir, deps);
|
|
expect(result.context["node:parse:value"]).toBe("parse-error");
|
|
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
|
|
unregisterPluginStepParsers("acme", ["boom"]);
|
|
});
|
|
|
|
it("a plugin parser returning a bad result maps to parse-error", async () => {
|
|
registerPluginStepParsers({
|
|
pluginId: "acme",
|
|
contributions: [{ parserId: "bad", parse: () => ({ steps: [{} as { name: string }] }) }],
|
|
});
|
|
const { deps, audits } = makeDeps({ readArtifact: async () => "x" });
|
|
const result = await runParse(parseIr("plugin:acme:bad"), deps);
|
|
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
|
|
expect(result.context["node:parse:value"]).toBe("parse-error");
|
|
unregisterPluginStepParsers("acme", ["bad"]);
|
|
});
|
|
|
|
it("registry rejects a non-namespaced plugin parser id", () => {
|
|
expect(() =>
|
|
registerPluginStepParsers({
|
|
pluginId: "acme",
|
|
// pluginParserRegistryId always namespaces, so registration succeeds —
|
|
// verify the resulting id is correctly namespaced.
|
|
contributions: [{ parserId: "ok", parse: () => ({ steps: [] }) }],
|
|
}),
|
|
).not.toThrow();
|
|
expect(getStepParserRegistry().has("plugin:acme:ok")).toBe(true);
|
|
unregisterPluginStepParsers("acme", ["ok"]);
|
|
});
|
|
});
|