test: expand /plan TUI coverage
This commit is contained in:
parent
ed70d596ec
commit
b6f9c31d05
@ -2,115 +2,146 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
// Mock clack prompts to simulate TUI interaction.
|
const CANCEL = Symbol.for("clack:cancel");
|
||||||
const clackHoisted = vi.hoisted(() => {
|
|
||||||
let selectCalls = 0;
|
|
||||||
const select = vi.fn(async ({ options, message }: any) => {
|
|
||||||
selectCalls += 1;
|
|
||||||
if (String(message).includes("Choose a section")) {
|
|
||||||
// First time: choose first section, second time: review.
|
|
||||||
if (selectCalls > 1) return "__review";
|
|
||||||
return options[0].value;
|
|
||||||
}
|
|
||||||
return options[0].value;
|
|
||||||
});
|
|
||||||
const multiselect = vi.fn(async ({ options }: any) => {
|
|
||||||
// pick all
|
|
||||||
return options.map((o: any) => o.value);
|
|
||||||
});
|
|
||||||
const text = vi.fn(async ({ initialValue }: any) => initialValue ?? "");
|
|
||||||
const confirm = vi.fn(async () => true);
|
|
||||||
const isCancel = vi.fn((v: any) => v === Symbol.for("clack:cancel"));
|
|
||||||
return { select, multiselect, text, confirm, isCancel };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@clack/prompts", async () => {
|
|
||||||
return {
|
|
||||||
confirm: clackHoisted.confirm,
|
|
||||||
isCancel: clackHoisted.isCancel,
|
|
||||||
select: clackHoisted.select,
|
|
||||||
multiselect: clackHoisted.multiselect,
|
|
||||||
text: clackHoisted.text,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
let calls = 0;
|
const state = {
|
||||||
const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => {
|
selectQueue: [] as any[],
|
||||||
calls += 1;
|
textQueue: [] as any[],
|
||||||
|
confirmQueue: [] as any[],
|
||||||
|
multiselectQueue: [] as any[],
|
||||||
|
|
||||||
|
// Embedded runner control
|
||||||
|
baseQuestions: [
|
||||||
|
{
|
||||||
|
id: "budget",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Budget?",
|
||||||
|
kind: "select",
|
||||||
|
required: true,
|
||||||
|
options: ["$", "$$"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deadline",
|
||||||
|
section: "Timeline",
|
||||||
|
prompt: "Deadline?",
|
||||||
|
kind: "text",
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
extraQuestions: [
|
||||||
|
{
|
||||||
|
id: "transport",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Preferred transport?",
|
||||||
|
kind: "multiselect",
|
||||||
|
required: true,
|
||||||
|
options: ["Car", "Plane"],
|
||||||
|
},
|
||||||
|
] as any[],
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetQueues() {
|
||||||
|
state.selectQueue = [];
|
||||||
|
state.textQueue = [];
|
||||||
|
state.confirmQueue = [];
|
||||||
|
state.multiselectQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const clack = {
|
||||||
|
select: vi.fn(async ({ options, message }: any) => {
|
||||||
|
const queued = state.selectQueue.shift();
|
||||||
|
if (queued !== undefined) return queued;
|
||||||
|
|
||||||
|
// Default behavior: choose first section once, then review.
|
||||||
|
if (String(message).includes("Choose a section")) {
|
||||||
|
// If we already chose a section once, go to review.
|
||||||
|
const already = (clack.select as any)._chosenOnce === true;
|
||||||
|
(clack.select as any)._chosenOnce = true;
|
||||||
|
if (already) return "__review";
|
||||||
|
return options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options[0].value;
|
||||||
|
}),
|
||||||
|
text: vi.fn(async ({ initialValue }: any) => {
|
||||||
|
const queued = state.textQueue.shift();
|
||||||
|
if (queued !== undefined) return queued;
|
||||||
|
return initialValue ?? "";
|
||||||
|
}),
|
||||||
|
confirm: vi.fn(async () => {
|
||||||
|
const queued = state.confirmQueue.shift();
|
||||||
|
if (queued !== undefined) return queued;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
multiselect: vi.fn(async ({ options }: any) => {
|
||||||
|
const queued = state.multiselectQueue.shift();
|
||||||
|
if (queued !== undefined) return queued;
|
||||||
|
return options.map((o: any) => o.value);
|
||||||
|
}),
|
||||||
|
isCancel: vi.fn((v: any) => v === CANCEL),
|
||||||
|
};
|
||||||
|
|
||||||
|
const embedded = {
|
||||||
|
runEmbeddedPiAgent: vi.fn(async ({ prompt }: any) => {
|
||||||
|
if (String(prompt).includes("Generate a compact questionnaire")) {
|
||||||
|
return {
|
||||||
|
payloads: [
|
||||||
|
{
|
||||||
|
text: JSON.stringify({
|
||||||
|
goal: "demo",
|
||||||
|
questions: state.baseQuestions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(prompt).includes("propose any missing high-signal questions")) {
|
||||||
|
return {
|
||||||
|
payloads: [
|
||||||
|
{
|
||||||
|
text: JSON.stringify({
|
||||||
|
goal: "demo",
|
||||||
|
questions: state.extraQuestions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (String(prompt).includes("Generate a compact questionnaire")) {
|
|
||||||
return {
|
return {
|
||||||
payloads: [
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
{
|
|
||||||
text: JSON.stringify({
|
|
||||||
goal: "demo",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: "budget",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Budget?",
|
|
||||||
kind: "select",
|
|
||||||
required: true,
|
|
||||||
options: ["$", "$$"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "deadline",
|
|
||||||
section: "Timeline",
|
|
||||||
prompt: "Deadline?",
|
|
||||||
kind: "text",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Extend prompt should return one multiselect question.
|
return { state, resetQueues, clack, embedded };
|
||||||
if (String(prompt).includes("propose any missing high-signal questions")) {
|
});
|
||||||
return {
|
|
||||||
payloads: [
|
|
||||||
{
|
|
||||||
text: JSON.stringify({
|
|
||||||
goal: "demo",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: "transport",
|
|
||||||
section: "Constraints",
|
|
||||||
prompt: "Preferred transport?",
|
|
||||||
kind: "multiselect",
|
|
||||||
required: true,
|
|
||||||
options: ["Car", "Plane"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
// Mock clack prompts to simulate TUI interaction.
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
vi.mock("@clack/prompts", async () => {
|
||||||
};
|
return {
|
||||||
});
|
confirm: hoisted.clack.confirm,
|
||||||
|
isCancel: hoisted.clack.isCancel,
|
||||||
return { runEmbeddedPiAgent };
|
select: hoisted.clack.select,
|
||||||
|
multiselect: hoisted.clack.multiselect,
|
||||||
|
text: hoisted.clack.text,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
|
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
|
||||||
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
|
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
|
||||||
runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent,
|
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
|
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
|
||||||
runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent,
|
runEmbeddedPiAgent: hoisted.embedded.runEmbeddedPiAgent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let testWorkspaceDir = os.tmpdir();
|
let testWorkspaceDir = os.tmpdir();
|
||||||
@ -124,6 +155,38 @@ afterAll(async () => {
|
|||||||
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
|
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.resetQueues();
|
||||||
|
(hoisted.clack.select as any)._chosenOnce = false;
|
||||||
|
// defaults
|
||||||
|
hoisted.state.baseQuestions = [
|
||||||
|
{
|
||||||
|
id: "budget",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Budget?",
|
||||||
|
kind: "select",
|
||||||
|
required: true,
|
||||||
|
options: ["$", "$$"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deadline",
|
||||||
|
section: "Timeline",
|
||||||
|
prompt: "Deadline?",
|
||||||
|
kind: "text",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
hoisted.state.extraQuestions = [
|
||||||
|
{
|
||||||
|
id: "transport",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Preferred transport?",
|
||||||
|
kind: "multiselect",
|
||||||
|
required: true,
|
||||||
|
options: ["Car", "Plane"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: commandBody,
|
Body: commandBody,
|
||||||
@ -162,9 +225,18 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getLatestPlanDir() {
|
||||||
|
const plansDir = path.join(testWorkspaceDir, "plans");
|
||||||
|
const entries = await fs.readdir(plansDir, { withFileTypes: true });
|
||||||
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||||
|
expect(dirs.length).toBeGreaterThan(0);
|
||||||
|
// Sort for determinism (timestamp prefix in name)
|
||||||
|
dirs.sort();
|
||||||
|
return path.join(plansDir, dirs[dirs.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
describe("/plan TUI", () => {
|
describe("/plan TUI", () => {
|
||||||
it("creates a plan directory and writes plan.md + answers.json", async () => {
|
it("creates a plan directory and writes plan.md + answers.json + questions.json", async () => {
|
||||||
// Make TTY true for interactive mode.
|
|
||||||
(process.stdin as any).isTTY = true;
|
(process.stdin as any).isTTY = true;
|
||||||
(process.stdout as any).isTTY = true;
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
@ -173,22 +245,152 @@ describe("/plan TUI", () => {
|
|||||||
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
const params = buildParams("/plan plan a trip", cfg);
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
expect(result.shouldContinue).toBe(false);
|
||||||
expect(result.reply?.text).toContain("Plan saved");
|
expect(result.reply?.text).toContain("Plan saved");
|
||||||
|
|
||||||
const plansDir = path.join(testWorkspaceDir, "plans");
|
const createdDir = await getLatestPlanDir();
|
||||||
const entries = await fs.readdir(plansDir, { withFileTypes: true });
|
|
||||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
||||||
expect(dirs.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const createdDir = path.join(plansDir, dirs[0]);
|
|
||||||
const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8");
|
const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8");
|
||||||
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
||||||
|
const questions = JSON.parse(
|
||||||
|
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
||||||
|
);
|
||||||
|
|
||||||
expect(planMd).toContain("# Plan");
|
expect(planMd).toContain("# Plan");
|
||||||
expect(Object.keys(answers).length).toBeGreaterThan(0);
|
expect(Object.keys(answers).length).toBeGreaterThan(0);
|
||||||
|
expect(questions.questions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports one-time extension at the end and uses multiselect for extra questions", async () => {
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
// Ensure we accept the extra questions and multiselect returns both.
|
||||||
|
hoisted.state.confirmQueue.push(true);
|
||||||
|
hoisted.state.multiselectQueue.push(["Car", "Plane"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
|
||||||
|
const createdDir = await getLatestPlanDir();
|
||||||
|
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
||||||
|
const questions = JSON.parse(
|
||||||
|
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(questions.questions.some((q: any) => q.id === "transport")).toBe(true);
|
||||||
|
expect(Array.isArray(answers.transport)).toBe(true);
|
||||||
|
expect(answers.transport).toEqual(["Car", "Plane"]);
|
||||||
|
expect(hoisted.clack.multiselect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ask extension confirm when extension returns no extra questions", async () => {
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
hoisted.state.extraQuestions = [];
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
|
||||||
|
// confirm might still be used for other prompts; but in this template it shouldn't.
|
||||||
|
// We assert it was not called with the extension message.
|
||||||
|
const confirmCalls = (hoisted.clack.confirm as any).mock.calls as any[];
|
||||||
|
const extensionCall = confirmCalls.find((c) =>
|
||||||
|
String(c?.[0]?.message ?? "").includes("tighter the plan"),
|
||||||
|
);
|
||||||
|
expect(extensionCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes extension questions by id", async () => {
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
// Extension repeats an existing id.
|
||||||
|
hoisted.state.extraQuestions = [
|
||||||
|
{
|
||||||
|
id: "budget",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Budget again?",
|
||||||
|
kind: "select",
|
||||||
|
required: true,
|
||||||
|
options: ["$"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
|
||||||
|
const createdDir = await getLatestPlanDir();
|
||||||
|
const questions = JSON.parse(
|
||||||
|
await fs.readFile(path.join(createdDir, "questions.json"), "utf-8"),
|
||||||
|
);
|
||||||
|
const ids = questions.questions.map((q: any) => q.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels cleanly if plan name prompt is cancelled", async () => {
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
hoisted.state.textQueue.push(CANCEL);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on required multiselect when user selects nothing", async () => {
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
// Force extension with a required multiselect and accept it.
|
||||||
|
hoisted.state.confirmQueue.push(true);
|
||||||
|
hoisted.state.multiselectQueue.push([]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Missing required answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run TUI handler when not in a TTY", async () => {
|
||||||
|
(process.stdin as any).isTTY = false;
|
||||||
|
(process.stdout as any).isTTY = false;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = await handleCommands(buildParams("/plan plan a trip", cfg));
|
||||||
|
expect(result.shouldContinue).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user