repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/ask/__tests__/contract.test.ts
Rawimport { fileURLToPath } from "node:url";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it } from "vitest";
import {
createPiSession,
type PiSession,
} from "../../../test/fixture/create-pi-session.ts";
import { __test } from "../index.ts";
let session: PiSession | undefined;
const KEYS: Record<string, string> = {
"tui.select.confirm": "\r",
"tui.select.cancel": "\x1b",
"tui.select.up": "\x1b[A",
"tui.select.down": "\x1b[B",
"tui.input.tab": "\t",
};
function tuiSession() {
let component:
| {
handleInput: (data: string) => void;
handleMouse?: (event: {
type: "press" | "click";
button: "left";
x: number;
y: number;
screenX: number;
screenY: number;
width: number;
height: number;
shift: boolean;
alt: boolean;
ctrl: boolean;
}) => { handled?: boolean } | undefined;
render: (width: number) => string[];
}
| undefined;
const custom = (
factory: (
tui: unknown,
theme: unknown,
keybindings: unknown,
done: (value: never) => void,
) => unknown,
) =>
new Promise((resolve) => {
component = factory(
{ requestRender() {}, terminal: { rows: 40 } },
{
fg: (_style: string, text: string) => text,
bold: (text: string) => text,
},
{
matches: (data: string, action: string) => KEYS[action] === data,
},
resolve,
) as typeof component;
});
const ctx = {
hasUI: true,
mode: "tui",
ui: { custom },
} as unknown as ExtensionContext;
return { ctx, component: () => component };
}
afterEach(async () => {
await session?.dispose();
session = undefined;
});
function fakeCtx(
ui: {
select?: (
title: string,
entries: string[],
options?: unknown,
) => Promise<string | undefined>;
input?: (
title: string,
placeholder?: string,
options?: unknown,
) => Promise<string | undefined>;
confirm?: (
title: string,
message: string,
options?: unknown,
) => Promise<boolean>;
},
overrides: Record<string, unknown> = {},
): ExtensionContext {
return {
hasUI: true,
mode: "rpc",
ui: {
select: ui.select ?? (async () => undefined),
input: ui.input ?? (async () => undefined),
confirm: ui.confirm ?? (async () => false),
},
...overrides,
} as ExtensionContext;
}
function mouseEvent(type: "press" | "click", y: number, width = 80) {
return {
type,
button: "left" as const,
x: 1,
y,
screenX: 1,
screenY: y,
width,
height: 40,
shift: false,
alt: false,
ctrl: false,
};
}
function singleQuestion() {
return __test.normalizeQuestions([
{
id: "db",
header: "Database",
question: "Which database?",
options: [
{ label: "Postgres (Recommended)", description: "Relational." },
{ label: "SQLite", description: "Embedded." },
],
},
]);
}
describe("ask extension", () => {
it("loads in real Pi extension runtime and registers ask_user_question", async () => {
session = await createPiSession({
extensionPaths: [fileURLToPath(new URL("../index.ts", import.meta.url))],
});
const tool =
session.extensions[0]!.tools.get("ask_user_question")?.definition;
expect(tool).toBeDefined();
if (!tool) throw new Error("tool missing");
expect(tool.exposure).toBe("model-only");
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.description).toContain(
"Non-interactive sessions return unavailable",
);
expect(tool.promptGuidelines).toBeDefined();
if (!tool.promptGuidelines) throw new Error("promptGuidelines missing");
expect(
tool.promptGuidelines.every((guideline: string) =>
guideline.includes("ask_user_question"),
),
).toBe(true);
});
it("terminates on cancel and reports missing UI at tool level", async () => {
session = await createPiSession({
extensionPaths: [fileURLToPath(new URL("../index.ts", import.meta.url))],
});
const tool = session.extensions[0]!.tools.get("ask_user_question");
const definition = tool?.definition;
expect(definition).toBeDefined();
if (!definition) throw new Error("definition missing");
const params = {
questions: [
{
question: "Q?",
options: [
{ label: "A", description: "a" },
{ label: "B", description: "b" },
],
},
],
};
const runner = session.session.extensionRunner;
const cancelled = await definition.execute(
"id",
params,
undefined,
undefined,
{ ...runner.createToolContext("id", undefined), ...fakeCtx({}) },
);
expect(cancelled.terminate).toBe(true);
const cancelledContent = cancelled.content[0];
if (cancelledContent?.type !== "text")
throw new Error("expected text content");
expect(JSON.parse(cancelledContent.text).status).toBe("cancelled");
const unavailable = await definition.execute(
"id",
params,
undefined,
undefined,
{
...runner.createToolContext("id", undefined),
...fakeCtx({}, { hasUI: false }),
},
);
const unavailableContent = unavailable.content[0];
if (unavailableContent?.type !== "text")
throw new Error("expected text content");
expect(JSON.parse(unavailableContent.text).status).toBe("unavailable");
});
});
describe("normalizeQuestions", () => {
it("derives key and header and strips model-supplied Other options", () => {
const [question] = __test.normalizeQuestions([
{
question: "Which database?",
options: [
{ label: "Postgres (Recommended)", description: "Relational." },
{ label: "Other", description: "ignored" },
],
},
]);
if (!question) throw new Error("question missing");
expect(question.key).toBe("Which database?");
expect(question.header).toBe("Which databa");
expect(question.options).toHaveLength(1);
expect(question.options[0]!.label).toBe("Postgres (Recommended)");
});
it("prefers id for key and header, dedupes keys, defaults multiSelect", () => {
const [first, second] = __test.normalizeQuestions([
{
id: "deploy",
header: "Target",
question: "Deploy where?",
options: [
{ label: "A", description: "a" },
{ label: "B", description: "b" },
],
},
{
id: "deploy",
question: "Again?",
options: [
{ label: "C", description: "c" },
{ label: "D", description: "d" },
],
},
]);
if (!first || !second) throw new Error("questions missing");
expect(first.key).toBe("deploy");
expect(first.header).toBe("Target");
expect(first.multiSelect).toBe(false);
expect(second.key).toBe("deploy-2");
});
it("rejects empty questions and questions without usable options", () => {
expect(() => __test.normalizeQuestions([])).toThrow(/1-4 questions/);
expect(() =>
__test.normalizeQuestions([
{
question: "Q?",
options: [{ label: "Other", description: "x" }],
},
]),
).toThrow(/no usable options/);
});
});
describe("runAskFlow", () => {
it("answers single-select with the chosen label and offers Other", async () => {
const calls: Array<[string, string[]]> = [];
const result = await __test.runAskFlow(
singleQuestion(),
fakeCtx({
select: async (title, entries) => {
calls.push([title, entries]);
return entries[0];
},
}),
);
expect(result).toEqual({
status: "answered",
answers: { db: { answers: ["Postgres (Recommended)"] } },
});
expect(calls[0]![0]).toBe("Question 1/1 · Database");
expect(calls[0]![1]).toContain(__test.OTHER_LABEL);
});
it("routes Other through free-text input and trims it", async () => {
const result = await __test.runAskFlow(
singleQuestion(),
fakeCtx({
select: async (_title, entries) => entries.at(-1) ?? __test.OTHER_LABEL,
input: async () => " Whatever fits ",
}),
);
expect(result).toEqual({
status: "answered",
answers: { db: { answers: ["Whatever fits"] } },
});
});
it("cancels the whole call when any dialog is dismissed", async () => {
const result = await __test.runAskFlow(
singleQuestion(),
fakeCtx({
select: async () => undefined,
input: async () => "x",
}),
);
expect(result).toEqual({ status: "cancelled" });
});
it("collects multi-select confirms, then Done", async () => {
const picks = [true, false];
const questions = __test.normalizeQuestions([
{
id: "feat",
question: "Which features?",
multiSelect: true,
options: [
{ label: "Auth", description: "a" },
{ label: "Audit", description: "b" },
],
},
]);
const result = await __test.runAskFlow(
questions,
fakeCtx({
select: async (_title, entries) =>
entries[0] === "Done" ? "Done" : undefined,
confirm: async () => picks.shift() ?? false,
}),
);
expect(result).toEqual({
status: "answered",
answers: { feat: { answers: ["Auth"] } },
});
});
});
describe("runAskFlow in TUI mode", () => {
const keys = Object.values(KEYS);
it("selects an option with enter", async () => {
const tui = tuiSession();
const flow = __test.runAskFlow(singleQuestion(), tui.ctx);
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(KEYS["tui.select.confirm"]!);
await expect(flow).resolves.toEqual({
status: "answered",
answers: { db: { answers: ["SQLite"] } },
});
});
it("routes Other through the free-text input", async () => {
const tui = tuiSession();
const flow = __test.runAskFlow(singleQuestion(), tui.ctx);
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(KEYS["tui.select.confirm"]!);
for (const key of ["C", "u", "s", "t", "o", "m", "\r"]) {
tui.component()?.handleInput(key);
}
await expect(flow).resolves.toEqual({
status: "answered",
answers: { db: { answers: ["Custom"] } },
});
});
it("attaches notes through tab and submits the highlighted option", async () => {
const tui = tuiSession();
const flow = __test.runAskFlow(singleQuestion(), tui.ctx);
tui.component()?.handleInput(KEYS["tui.input.tab"]!);
for (const key of ["l", "a", "t", "e", "r", "\r"]) {
tui.component()?.handleInput(key);
}
await expect(flow).resolves.toEqual({
status: "answered",
answers: { db: { answers: ["Postgres (Recommended)"], notes: "later" } },
});
});
it("wraps questions, options, and free-text input to the available width", async () => {
const questions = __test.normalizeQuestions([
{
id: "wrap",
question:
"Which database should handle this unusually demanding workload?",
options: [
{
label: "Postgres with replication (Recommended)",
description: "Supports relational queries and resilient failover.",
},
{ label: "SQLite", description: "Embedded and deliberately simple." },
],
},
]);
const tui = tuiSession();
const flow = __test.runAskFlow(questions, tui.ctx);
const initial = tui.component()?.render(24) ?? [];
expect(initial.every((line) => visibleWidth(line) <= 24)).toBe(true);
expect(initial.join("\n")).toContain("demanding workload?");
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(KEYS["tui.select.confirm"]!);
for (const key of "A free-form answer long enough to wrap") {
tui.component()?.handleInput(key);
}
const withInput = tui.component()?.render(24) ?? [];
expect(withInput.every((line) => visibleWidth(line) <= 24)).toBe(true);
expect(
withInput.filter(
(line) => line.includes("free-form") || line.includes("enough to wrap"),
),
).toHaveLength(2);
tui.component()?.handleInput("\r");
await expect(flow).resolves.toEqual({
status: "answered",
answers: {
wrap: { answers: ["A free-form answer long enough to wrap"] },
},
});
});
it("selects an option with the mouse in fullscreen TUI mode", async () => {
const tui = tuiSession();
const flow = __test.runAskFlow(singleQuestion(), tui.ctx);
const lines = tui.component()?.render(80) ?? [];
const sqliteRow = lines.findIndex((line) => line.includes("SQLite"));
expect(sqliteRow).toBeGreaterThanOrEqual(0);
tui.component()?.handleMouse?.(mouseEvent("press", sqliteRow));
tui.component()?.handleMouse?.(mouseEvent("click", sqliteRow));
await expect(flow).resolves.toEqual({
status: "answered",
answers: { db: { answers: ["SQLite"] } },
});
});
it("cancels from the option list with escape", async () => {
const tui = tuiSession();
const flow = __test.runAskFlow(singleQuestion(), tui.ctx);
tui.component()?.handleInput(KEYS["tui.select.cancel"]!);
await expect(flow).resolves.toEqual({ status: "cancelled" });
});
it("toggles multi-select options with space", async () => {
const questions = __test.normalizeQuestions([
{
id: "feat",
question: "Which features?",
multiSelect: true,
options: [
{ label: "Auth", description: "a" },
{ label: "Audit", description: "b" },
],
},
]);
const tui = tuiSession();
const flow = __test.runAskFlow(questions, tui.ctx);
tui.component()?.handleInput(" ");
tui.component()?.handleInput(KEYS["tui.select.down"]!);
tui.component()?.handleInput(" ");
const rendered = tui.component()?.render(80)?.join("\n") ?? "";
tui.component()?.handleInput(KEYS["tui.select.confirm"]!);
await expect(flow).resolves.toEqual({
status: "answered",
answers: { feat: { answers: ["Auth", "Audit"] } },
});
expect(rendered).toContain("Which features?");
expect(rendered).toContain("Other");
expect(keys).toContain(KEYS["tui.select.confirm"]);
});
});