Skip to content

Commit 90b6f21

Browse files
committed
test: add coverage for plugin and runtime edges
1 parent fdcaf7f commit 90b6f21

2 files changed

Lines changed: 174 additions & 5 deletions

File tree

tests/config.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import { tool } from "@opencode-ai/plugin";
3-
import { applyFlowConfig } from "../src/config";
3+
import FlowPlugin from "../src/index";
4+
import { applyFlowConfig, createConfigHook } from "../src/config";
45
import { FLOW_AUTO_COMMAND_TEMPLATE, FLOW_RUN_COMMAND_TEMPLATE } from "../src/prompts/commands";
56
import { FLOW_AUTO_AGENT_PROMPT, FLOW_REVIEWER_AGENT_PROMPT, FLOW_WORKER_AGENT_PROMPT } from "../src/prompts/agents";
67
import { FLOW_REVIEWER_CONTRACT, FLOW_WORKER_CONTRACT } from "../src/prompts/contracts";
@@ -19,6 +20,21 @@ function getToolSchemas() {
1920
}
2021

2122
describe("applyFlowConfig", () => {
23+
test("plugin entrypoint returns Flow config and tool hooks", async () => {
24+
const ctx = { worktree: "/tmp/flow-plugin-test" } as any;
25+
const plugin = await FlowPlugin(ctx);
26+
27+
expect(typeof plugin.config).toBe("function");
28+
expect(plugin.tool).toBeDefined();
29+
expect(Object.keys(plugin.tool ?? {})).toEqual(Object.keys(createTools(ctx)));
30+
31+
const config = { command: { existing: { description: "keep me" } } } as any;
32+
await plugin.config?.(config);
33+
34+
expect(config.command.existing).toEqual({ description: "keep me" });
35+
expect(config.command["flow-plan"]).toBeDefined();
36+
});
37+
2238
test("injects commands and agents", () => {
2339
const config: { agent?: Record<string, unknown>; command?: Record<string, unknown> } = {};
2440
applyFlowConfig(config);
@@ -54,6 +70,21 @@ describe("applyFlowConfig", () => {
5470
expect(config.agent?.["flow-reviewer"]?.tools?.bash).toBe(false);
5571
});
5672

73+
test("createConfigHook is async and preserves unrelated config entries", async () => {
74+
const hook = createConfigHook({});
75+
const config = {
76+
agent: { existing: { mode: "primary", description: "already here" } },
77+
command: { existing: { description: "already here", agent: "existing" } },
78+
} as any;
79+
80+
await expect(hook(config)).resolves.toBeUndefined();
81+
82+
expect(config.agent.existing).toEqual({ mode: "primary", description: "already here" });
83+
expect(config.command.existing).toEqual({ description: "already here", agent: "existing" });
84+
expect(config.agent["flow-control"]).toBeDefined();
85+
expect(config.command["flow-reset"]).toBeDefined();
86+
});
87+
5788
test("exports sdk-compatible raw arg shapes for every tool", () => {
5889
const { tools, schemas } = getToolSchemas();
5990

tests/runtime.test.ts

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { mkdtempSync, rmSync } from "node:fs";
3-
import { readFile } from "node:fs/promises";
2+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3+
import { readFile, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { createSession, deleteSession, loadSession, saveSession } from "../src/runtime/session";
7-
import { getFeatureDocPath, getIndexDocPath } from "../src/runtime/paths";
8-
import { summarizeSession } from "../src/runtime/summary";
7+
import { getFeatureDocPath, getIndexDocPath, getSessionPath } from "../src/runtime/paths";
8+
import { deriveNextCommand, summarizeSession } from "../src/runtime/summary";
99
import { createTools } from "../src/tools";
1010
import { approvePlan, applyPlan, completeRun, recordReviewerDecision, resetFeature, selectPlanFeatures, startRun } from "../src/runtime/transitions";
1111

@@ -63,6 +63,27 @@ describe("runtime transitions", () => {
6363
expect(indexDoc).toContain("goal: Build a workflow plugin");
6464
});
6565

66+
test("rejects malformed persisted session data", async () => {
67+
const worktree = makeTempDir();
68+
mkdirSync(join(worktree, ".flow"), { recursive: true });
69+
await writeFile(getSessionPath(worktree), "{not valid json", "utf8");
70+
71+
await expect(loadSession(worktree)).rejects.toThrow();
72+
});
73+
74+
test("saveSession refreshes updatedAt while preserving createdAt", async () => {
75+
const worktree = makeTempDir();
76+
const created = createSession("Build a workflow plugin");
77+
const firstSave = await saveSession(worktree, created);
78+
79+
await new Promise((resolve) => setTimeout(resolve, 10));
80+
81+
const secondSave = await saveSession(worktree, firstSave);
82+
83+
expect(secondSave.timestamps.createdAt).toBe(firstSave.timestamps.createdAt);
84+
expect(new Date(secondSave.timestamps.updatedAt).getTime()).toBeGreaterThan(new Date(firstSave.timestamps.updatedAt).getTime());
85+
});
86+
6687
test("renders feature docs for planned work", async () => {
6788
const worktree = makeTempDir();
6889
const session = createSession("Build a workflow plugin");
@@ -78,6 +99,26 @@ describe("runtime transitions", () => {
7899
expect(featureDoc).toContain("src/runtime/session.ts");
79100
});
80101

102+
test("prunes stale feature docs when a plan is narrowed", async () => {
103+
const worktree = makeTempDir();
104+
const session = createSession("Build a workflow plugin");
105+
const applied = applyPlan(session, samplePlan());
106+
expect(applied.ok).toBe(true);
107+
if (!applied.ok) return;
108+
109+
await saveSession(worktree, applied.value);
110+
await expect(readFile(getFeatureDocPath(worktree, "execute-feature"), "utf8")).resolves.toContain("# Feature execute-feature");
111+
112+
const selected = selectPlanFeatures(applied.value, ["setup-runtime"]);
113+
expect(selected.ok).toBe(true);
114+
if (!selected.ok) return;
115+
116+
await saveSession(worktree, selected.value);
117+
118+
await expect(readFile(getFeatureDocPath(worktree, "setup-runtime"), "utf8")).resolves.toContain("# Feature setup-runtime");
119+
await expect(readFile(getFeatureDocPath(worktree, "execute-feature"), "utf8")).rejects.toThrow();
120+
});
121+
81122
test("renders multiline content without breaking markdown structure", async () => {
82123
const worktree = makeTempDir();
83124
const session = createSession("Build a workflow plugin\nwith multiline context");
@@ -721,6 +762,55 @@ describe("runtime transitions", () => {
721762
expect(indexDoc).toContain("next step: none");
722763
});
723764

765+
test("summarizeSession reports missing state when no session exists", () => {
766+
expect(summarizeSession(null)).toEqual({
767+
status: "missing",
768+
summary: "No active Flow session found.",
769+
});
770+
});
771+
772+
test("deriveNextCommand covers planning, runnable, blocked-human, and completed branches", () => {
773+
const planning = createSession("Build a workflow plugin");
774+
expect(deriveNextCommand(planning)).toBe("/flow-plan <goal>");
775+
776+
const applied = applyPlan(planning, samplePlan());
777+
expect(applied.ok).toBe(true);
778+
if (!applied.ok) return;
779+
780+
expect(deriveNextCommand(applied.value)).toBe("/flow-plan");
781+
782+
const approved = approvePlan(applied.value);
783+
expect(approved.ok).toBe(true);
784+
if (!approved.ok) return;
785+
786+
expect(deriveNextCommand(approved.value)).toBe("/flow-run");
787+
788+
const running = startRun(approved.value);
789+
expect(running.ok).toBe(true);
790+
if (!running.ok) return;
791+
792+
expect(deriveNextCommand(running.value.session)).toBe("/flow-run");
793+
794+
const blocked = {
795+
...approved.value,
796+
status: "blocked" as const,
797+
execution: {
798+
...approved.value.execution,
799+
lastFeatureId: "setup-runtime",
800+
lastOutcome: {
801+
kind: "blocked_external" as const,
802+
summary: "Waiting on human decision.",
803+
needsHuman: true,
804+
},
805+
},
806+
};
807+
808+
expect(deriveNextCommand(blocked)).toBe("/flow-status");
809+
810+
const completed = { ...approved.value, status: "completed" as const };
811+
expect(deriveNextCommand(completed)).toBe("/flow-plan <goal>");
812+
});
813+
724814
test("suggests resetting blocked features when the outcome is retryable", () => {
725815
const session = createSession("Build a workflow plugin");
726816
const applied = applyPlan(session, samplePlan());
@@ -1200,6 +1290,54 @@ describe("runtime transitions", () => {
12001290
expect(parsed.session.lastOutcomeKind).toBe("completed");
12011291
});
12021292

1293+
test("flow_status returns a machine-readable missing-session summary", async () => {
1294+
const worktree = makeTempDir();
1295+
const tools = createTools({}) as any;
1296+
const response = await tools.flow_status.execute({}, { worktree });
1297+
const parsed = JSON.parse(response);
1298+
1299+
expect(parsed.status).toBe("missing");
1300+
expect(parsed.summary).toBe("No active Flow session found.");
1301+
});
1302+
1303+
test("flow_reset_session clears persisted session state and docs", async () => {
1304+
const worktree = makeTempDir();
1305+
const tools = createTools({}) as any;
1306+
await saveSession(worktree, createSession("Build a workflow plugin"));
1307+
1308+
const response = await tools.flow_reset_session.execute({}, { worktree });
1309+
const parsed = JSON.parse(response);
1310+
1311+
expect(parsed.status).toBe("ok");
1312+
expect(parsed.nextCommand).toBe("/flow-plan <goal>");
1313+
expect(await loadSession(worktree)).toBeNull();
1314+
await expect(readFile(getIndexDocPath(worktree), "utf8")).rejects.toThrow();
1315+
});
1316+
1317+
test("tools return machine-readable missing-session responses for plan, review, and reset operations", async () => {
1318+
const worktree = makeTempDir();
1319+
const tools = createTools({}) as any;
1320+
const cases = [
1321+
["flow_plan_apply", { plan: samplePlan() }, "missing_session", "/flow-plan <goal>"],
1322+
["flow_plan_approve", {}, "missing_session", undefined],
1323+
["flow_plan_select_features", { featureIds: ["setup-runtime"] }, "missing_session", undefined],
1324+
["flow_review_record_feature", { scope: "feature", featureId: "setup-runtime", status: "approved", summary: "Looks good." }, "missing_session", undefined],
1325+
["flow_review_record_final", { scope: "final", status: "approved", summary: "Looks good." }, "missing_session", undefined],
1326+
["flow_reset_feature", { featureId: "setup-runtime" }, "missing_session", undefined],
1327+
] as const;
1328+
1329+
for (const [toolName, args, expectedStatus, expectedNextCommand] of cases) {
1330+
const response = await tools[toolName].execute(args, { worktree });
1331+
const parsed = JSON.parse(response);
1332+
1333+
expect(parsed.status).toBe(expectedStatus);
1334+
expect(parsed.summary).toContain("No active Flow");
1335+
if (expectedNextCommand) {
1336+
expect(parsed.nextCommand).toBe(expectedNextCommand);
1337+
}
1338+
}
1339+
});
1340+
12031341
test("tool rejects flow_run_start for completed sessions", async () => {
12041342
const worktree = makeTempDir();
12051343
const tools = createTools({}) as any;

0 commit comments

Comments
 (0)