Files
oh-my-pi/packages/coding-agent/test/modes/components/ask-dialog.test.ts
T
roboomp 8a059e3027 fix(coding-agent): normalize ask dialog questions to prevent render crash
The live AskDialog trusted question.question while its render helpers
(replaceTabs, renderQuestionTitle, questionTabLabel) assume a string. A
question reaching AskDialogComponent without a string question field threw
an uncaught TypeError that escaped the TUI render loop and killed the
session. The transcript renderer already normalizes the same malformed
data via normalizeRenderQuestions; the live path did not.

Normalize the questions array at dialog entry (new normalizeDialogQuestions),
coercing question/id/label to strings and options to a well-formed array,
matching the transcript path.

Fixes #7211
2026-08-01 00:11:21 +00:00

1388 lines
43 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
import type { ExtensionAskDialogQuestion } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import { AskDialogComponent } from "@oh-my-pi/pi-coding-agent/modes/components/ask-dialog";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { setKeybindings } from "@oh-my-pi/pi-tui";
const DOWN = "\x1b[B";
const UP = "\x1b[A";
const PAGE_DOWN = "\x1b[6~";
const PAGE_UP = "\x1b[5~";
const ENTER = "\n";
const CANCEL = "\x07";
const SPACE = " ";
const TAB = "\t";
const SHIFT_TAB = "\x1b[Z";
let darkTheme = await getThemeByName("dark");
function render(component: AskDialogComponent): string {
return stripVTControlCharacters(component.render(80).join("\n"));
}
describe("AskDialogComponent", () => {
beforeAll(async () => {
darkTheme = await getThemeByName("dark");
if (!darkTheme) throw new Error("Failed to load dark theme");
});
beforeEach(() => {
setThemeInstance(darkTheme!);
setKeybindings(KeybindingsManager.inMemory({ "tui.select.cancel": "ctrl+g" }));
});
afterEach(() => {
setKeybindings(KeybindingsManager.inMemory());
vi.useRealTimers();
vi.restoreAllMocks();
});
it("single-question, single-select: Enter on option submits immediately", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toEqual({
kind: "submit",
results: [
{
id: "q1",
question: "Choose one?",
options: ["Option A", "Option B"],
multi: false,
selectedOptions: ["Option A"],
customInput: undefined,
note: undefined,
timedOut: undefined,
},
],
});
});
it("single-question, single-select: Space does not submit the highlighted answer", () => {
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
component.handleInput(SPACE);
expect(onSubmit).not.toHaveBeenCalled();
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]);
});
it("single-question, single-select: DOWN then Enter selects second option and submits", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]);
});
it("multi-question, single-select: Enter on option advances tab, does not submit", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Q1?",
options: [{ label: "A1" }, { label: "B1" }],
},
{
id: "q2",
question: "Q2?",
options: [{ label: "A2" }, { label: "B2" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
// Press Enter on A1 - should advance tab to Q2 (tab 1), not submit
component.handleInput(ENTER);
expect(onSubmit).not.toHaveBeenCalled();
// On Q2: Down to B2 and Enter - should advance tab to Submit (tab 2), not submit
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onSubmit).not.toHaveBeenCalled();
// On Submit tab: Enter on Submit row - should submit
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results).toEqual([
{
id: "q1",
question: "Q1?",
options: ["A1", "B1"],
multi: false,
selectedOptions: ["A1"],
customInput: undefined,
note: undefined,
timedOut: undefined,
},
{
id: "q2",
question: "Q2?",
options: ["A2", "B2"],
multi: false,
selectedOptions: ["B2"],
customInput: undefined,
note: undefined,
timedOut: undefined,
},
]);
});
it("multi-select: Space and Enter both toggle without advancing; Submit tab confirms", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
// Space on Option A - toggles without advancing
component.handleInput(SPACE);
expect(onSubmit).not.toHaveBeenCalled();
// Down to Option B, Enter - toggles B, still no submit and no movement
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onSubmit).not.toHaveBeenCalled();
// Tab to the Submit tab (present even for a single multi question),
// Enter confirms the selection.
component.handleInput(TAB);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A", "Option B"]);
});
it("tab-state persistence: answer question 0, Tab forward, Tab back, answer still present", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Q1?",
options: [{ label: "A1" }, { label: "B1" }],
},
{
id: "q2",
question: "Q2?",
options: [{ label: "A2" }, { label: "B2" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
// Enter on A1 selects it and auto-advances to Q2 (tab 1)
component.handleInput(ENTER);
// Shift+Tab back to Q1 (tab 0)
component.handleInput(SHIFT_TAB);
// Enter again on Q1's currently selected option (which will re-select/keep it and auto-advance to Q2)
component.handleInput(ENTER);
// On Q2: select B2 and advance to Submit
component.handleInput(DOWN);
component.handleInput(ENTER);
// On Submit: Enter to submit
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["A1"]);
expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]);
});
it("Tab and Shift+Tab switches tabs", () => {
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Q1?",
options: [{ label: "A1" }, { label: "B1" }],
},
{
id: "q2",
question: "Q2?",
options: [{ label: "A2" }, { label: "B2" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// Tab from Q1 -> Q2
component.handleInput(TAB);
// Tab from Q2 -> Submit
component.handleInput(TAB);
// Shift+Tab from Submit -> Q2
component.handleInput(SHIFT_TAB);
// Down to B2, Enter -> Submit
component.handleInput(DOWN);
component.handleInput(ENTER);
// Enter on Submit
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]);
expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]);
});
it("Submit tab shows unanswered warning but Enter still submits", () => {
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Q1?",
options: [{ label: "A1" }, { label: "B1" }],
},
{
id: "q2",
question: "Q2?",
options: [{ label: "A2" }, { label: "B2" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// Tab to Submit
component.handleInput(TAB);
component.handleInput(TAB);
const output = render(component);
expect(output.toLowerCase()).toContain("unanswered");
// Enter on Submit
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]);
expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual([]);
});
it("Esc/cancel fires onCancel", () => {
const onCancel = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel,
onPrompt: vi.fn(),
});
component.handleInput(CANCEL);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("n on an option calls onPrompt and stores note with marker", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("My Custom Note"));
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
// Highlight is on Option A. Press 'n'.
component.handleInput("n");
// Await microtasks so the async #promptForNote runs
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(1);
expect(onPrompt.mock.calls[0][0]).toBe("Note for Option A: Choose one?");
// Verify note is saved by submitting
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].note).toBe("My Custom Note");
});
it("note prefill is empty when editing a different row after noting another option", async () => {
const onPrompt = vi.fn();
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
// Cursor starts on Option A. Add a note for A.
onPrompt.mockReturnValueOnce(Promise.resolve("Note for A"));
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(1);
expect(onPrompt.mock.calls[0][0]).toBe("Note for Option A: Choose one?");
// No prior note → prefill is undefined.
expect(onPrompt.mock.calls[0][1]).toBeUndefined();
// Move down to Option B and open its note.
component.handleInput(DOWN);
onPrompt.mockReturnValueOnce(Promise.resolve("Note for B"));
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(2);
// Prefill for Option B must be undefined — not the note from Option A.
expect(onPrompt.mock.calls[1][1]).toBeUndefined();
// Move back up to Option A and re-open its note.
component.handleInput("\x1b[A"); // UP
onPrompt.mockReturnValueOnce(Promise.resolve("Updated note"));
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(3);
// Note now belongs to Option B, so re-editing Option A starts empty.
expect(onPrompt.mock.calls[2][1]).toBeUndefined();
});
it("note prefill reuses the existing note when re-editing the same row", async () => {
const onPrompt = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt,
});
// Add a note on Option A.
onPrompt.mockReturnValueOnce(Promise.resolve("My note"));
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
// Re-open the note on the same row (cursor still on Option A).
onPrompt.mockReturnValueOnce(Promise.resolve("Updated note"));
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(2);
// Same row → prefill reuses the existing note.
expect(onPrompt.mock.calls[1][1]).toBe("My note");
});
it("omits a note when a single-select answer changes to a different option", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A"));
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]);
expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined();
});
it("clears the note when a noted multi-select option is toggled off", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A"));
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
component.handleInput(SPACE);
component.handleInput(SPACE);
expect(render(component)).not.toContain("✎ note");
// Select Option B and confirm from the Submit tab; the cleared note
// must not resurface.
component.handleInput(DOWN);
component.handleInput(SPACE);
component.handleInput(TAB);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]);
expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined();
});
it("shows selected multi-select options together with custom input on Submit", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("custom detail"));
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
{
id: "q2",
question: "Second question?",
options: [{ label: "Option C" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
component.handleInput(SPACE);
component.handleInput(DOWN);
component.handleInput(DOWN);
component.handleInput(ENTER);
await Promise.resolve();
await Promise.resolve();
// Multi questions do not auto-advance after the Other prompt: still on
// q1, so Tab twice (q2, then Submit) to reach the review.
component.handleInput(TAB);
component.handleInput(TAB);
const review = render(component);
expect(review).toContain("Option A");
expect(review).toContain("custom detail");
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]);
expect(onSubmit.mock.calls[0][0].results[0].customInput).toBe("custom detail");
});
it("defers a timeout that fires during a pending prompt and honors the resolved custom input", async () => {
vi.useFakeTimers();
const deferred = Promise.withResolvers<string | undefined>();
const onPrompt = vi.fn().mockReturnValue(deferred.promise);
const onSubmit = vi.fn();
const onTimeout = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "First?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
{
id: "q2",
question: "Second?",
options: [{ label: "Option C" }, { label: "Option D" }],
recommended: 1,
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit, onCancel: vi.fn(), onPrompt },
{ timeout: 1000, onTimeout },
);
// Open the "Other (type your own)" prompt on question 1.
component.handleInput(DOWN);
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onPrompt).toHaveBeenCalledTimes(1);
// Timer expires while the prompt is pending: the timeout must be deferred,
// not submit the recommended fallback out from under the user.
vi.advanceTimersByTime(1000);
expect(onTimeout).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
// Resolving the prompt honors the typed answer, then runs the deferred
// timeout handling exactly once.
deferred.resolve("my answer");
await Promise.resolve();
await Promise.resolve();
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
const results = onSubmit.mock.calls[0][0].results;
expect(results[0].customInput).toBe("my answer");
expect(results[0].selectedOptions).toEqual([]);
expect(results[0].timedOut).toBeUndefined();
expect(results[1].selectedOptions).toEqual(["Option D"]);
expect(results[1].timedOut).toBe(true);
});
it("keeps a single-question custom prompt answer when timeout expires while the prompt is pending", async () => {
vi.useFakeTimers();
const deferred = Promise.withResolvers<string | undefined>();
const onPrompt = vi.fn().mockReturnValue(deferred.promise);
const onSubmit = vi.fn();
const onTimeout = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Only question?",
options: [{ label: "Fallback" }],
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit, onCancel: vi.fn(), onPrompt },
{ timeout: 1000, onTimeout },
);
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onPrompt).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
expect(onTimeout).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
deferred.resolve("my answer");
await Promise.resolve();
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onTimeout).not.toHaveBeenCalled();
const result = onSubmit.mock.calls[0][0].results[0];
expect(result.customInput).toBe("my answer");
expect(result.selectedOptions).toEqual([]);
expect(result.timedOut).toBeUndefined();
});
it("uses a noted non-recommended option as the timeout fallback", async () => {
vi.useFakeTimers();
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("why B"));
const onSubmit = vi.fn();
const onTimeout = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
recommended: 0,
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit, onCancel: vi.fn(), onPrompt },
{ timeout: 1000, onTimeout },
);
component.handleInput(DOWN);
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
vi.advanceTimersByTime(1000);
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
const result = onSubmit.mock.calls[0][0].results[0];
expect(result.selectedOptions).toEqual(["Option B"]);
expect(result.note).toBe("why B");
expect(result.timedOut).toBe(true);
});
it("preserves a pending note on a non-recommended option when deferred timeout submits", async () => {
vi.useFakeTimers();
const deferred = Promise.withResolvers<string | undefined>();
const onPrompt = vi.fn().mockReturnValue(deferred.promise);
const onSubmit = vi.fn();
const onTimeout = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
recommended: 0,
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit, onCancel: vi.fn(), onPrompt },
{ timeout: 1000, onTimeout },
);
component.handleInput(DOWN);
component.handleInput("n");
expect(onPrompt).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
expect(onTimeout).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
deferred.resolve("why B");
await Promise.resolve();
await Promise.resolve();
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
const result = onSubmit.mock.calls[0][0].results[0];
expect(result.selectedOptions).toEqual(["Option B"]);
expect(result.note).toBe("why B");
expect(result.timedOut).toBe(true);
});
it("resets the inactivity countdown on user input after the closed/prompt guard", () => {
vi.useFakeTimers();
const onTimeout = vi.fn();
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit, onCancel: vi.fn(), onPrompt: vi.fn() },
{ timeout: 5000, onTimeout },
);
// Advance most of the timeout window.
vi.advanceTimersByTime(4000);
expect(onTimeout).not.toHaveBeenCalled();
// User input (DOWN) should reset the countdown.
component.handleInput(DOWN);
// Advancing past the *original* deadline must NOT fire the timeout —
// the reset moved the deadline forward by the interaction.
vi.advanceTimersByTime(2000);
expect(onTimeout).not.toHaveBeenCalled();
// Advancing the remaining time after the reset DOES fire.
vi.advanceTimersByTime(3000);
expect(onTimeout).toHaveBeenCalledTimes(1);
});
it("does not reset the countdown while a prompt is active", async () => {
vi.useFakeTimers();
const deferred = Promise.withResolvers<string | undefined>();
const onPrompt = vi.fn().mockReturnValue(deferred.promise);
const onTimeout = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(
questions,
{ onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt },
{ timeout: 5000, onTimeout },
);
// Open the custom-input prompt (DOWN to "Other", ENTER).
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onPrompt).toHaveBeenCalledTimes(1);
// While the prompt is pending, input is guarded — no reset.
component.handleInput(DOWN);
vi.advanceTimersByTime(5000);
// Timeout is deferred during prompt, not fired.
expect(onTimeout).not.toHaveBeenCalled();
deferred.resolve("answer");
await Promise.resolve();
await Promise.resolve();
});
it("bounds custom input prompt title for long multi-line questions", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("custom"));
const longQuestion = "This is a very long question ".repeat(20);
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: longQuestion,
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt,
});
// Navigate to "Other" and press Enter to trigger the custom prompt.
component.handleInput(DOWN);
component.handleInput(ENTER);
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(1);
const title = onPrompt.mock.calls[0][0] as string;
const lines = title.split("\n");
// Title must be bounded to at most MAX_PROMPT_TITLE_ROWS lines.
expect(lines.length).toBeLessThanOrEqual(3);
// Each line must fit within the terminal content width.
for (const line of lines) {
expect(stripVTControlCharacters(line).length).toBeLessThanOrEqual((process.stdout.columns ?? 80) - 4);
}
// Must contain the prefix and a truncation indicator on the last line.
expect(stripVTControlCharacters(title)).toContain("Custom answer:");
});
it("bounds note prompt title for long multi-line questions", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve("note"));
const longQuestion = "Multi\nline\nquestion ".repeat(30);
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: longQuestion,
options: [{ label: "Option A" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt,
});
// Press 'n' on the highlighted option to trigger the note prompt.
component.handleInput("n");
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenCalledTimes(1);
const title = onPrompt.mock.calls[0][0] as string;
const lines = title.split("\n");
// Title must be bounded to at most MAX_PROMPT_TITLE_ROWS lines.
expect(lines.length).toBeLessThanOrEqual(3);
// The multi-line question must be flattened (no raw newlines expanding rows).
expect(stripVTControlCharacters(title)).toContain("Note for Option A:");
});
it("scrolls question rows when cursor moves below the viewport", () => {
const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 });
try {
const options = Array.from({ length: 30 }, (_, i) => ({
label: `Option ${String(i + 1).padStart(2, "0")}`,
}));
const questions: ExtensionAskDialogQuestion[] = [{ id: "q1", question: "Pick one?", options }];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
const renderAt = (width: number): string => stripVTControlCharacters(component.render(width).join("\n"));
const initial = renderAt(60);
expect(initial).toContain("Option 01");
expect(initial).not.toContain("Option 30");
expect(initial).toContain("↓ scroll");
for (let i = 0; i < 28; i++) component.handleInput(DOWN);
const scrolled = renderAt(60);
expect(scrolled).not.toContain("Option 01");
expect(scrolled).toContain("Option 29");
} finally {
if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows);
else Reflect.deleteProperty(process.stdout, "rows");
}
});
it("single-question multi-select: Enter toggles instead of submitting", () => {
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// Enter on Option B toggles it — no submit, no tab movement.
component.handleInput(DOWN);
component.handleInput(ENTER);
expect(onSubmit).not.toHaveBeenCalled();
// The toggle registered: Submit tab confirms only Option B.
component.handleInput(TAB);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]);
});
it("multi-select: Enter on a checked option toggles it off; empty answer submits from Submit tab", () => {
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// Space checks Option A, Enter on the same row unchecks it.
component.handleInput(SPACE);
component.handleInput(ENTER);
// Submit tab warns about the unanswered question but still submits.
component.handleInput(TAB);
expect(render(component).toLowerCase()).toContain("unanswered");
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]);
});
it("renders every option's preview inline, not only the highlighted one", () => {
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Pick one?",
options: [
{ label: "Alpha", preview: "PREVIEW-ALPHA" },
{ label: "Bravo", preview: "PREVIEW-BRAVO" },
{ label: "Charlie" },
],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// Cursor defaults to option 0; both previews must be visible without navigating.
const out = stripVTControlCharacters(component.render(80).join("\n"));
expect(out).toContain("PREVIEW-ALPHA");
expect(out).toContain("PREVIEW-BRAVO");
});
it("refreshes cached preview styling after theme invalidation", async () => {
const createComponent = (): AskDialogComponent =>
new AskDialogComponent(
[{ id: "q1", question: "Pick one?", options: [{ label: "Alpha", preview: "CACHE-PREVIEW" }] }],
{ onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() },
);
const previewLine = (component: AskDialogComponent): string =>
component.render(80).find(line => line.includes("CACHE-PREVIEW")) ?? "";
const originalTheme = darkTheme;
if (!originalTheme) throw new Error("Failed to load dark theme");
const lightTheme = await getThemeByName("light");
if (!lightTheme) throw new Error("Failed to load light theme");
const cachedComponent = createComponent();
const before = previewLine(cachedComponent);
expect(stripVTControlCharacters(before)).toContain("│ CACHE-PREVIEW");
try {
setThemeInstance(lightTheme);
const stale = previewLine(cachedComponent);
const fresh = previewLine(createComponent());
expect(stripVTControlCharacters(stale)).toBe(stripVTControlCharacters(fresh));
expect(stale).not.toBe(fresh);
cachedComponent.invalidate();
expect(previewLine(cachedComponent)).toBe(fresh);
} finally {
setThemeInstance(originalTheme);
cachedComponent.invalidate();
}
});
it("keeps the memoized overflowing render identical to the initial width-adjusted render", () => {
const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 });
try {
const edgeLine = `${"X".repeat(67)}Ω`;
const filler = Array.from({ length: 30 }, (_, index) => `filler-${index}`).join("\n");
const component = new AskDialogComponent(
[
{
id: "q1",
question: "Inspect?",
options: [{ label: "Alpha", preview: `\`\`\`\n${edgeLine}\n${filler}\n\`\`\`` }],
},
],
{ onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() },
);
const initial = render(component);
const cached = render(component);
expect(initial).toContain("Ω");
expect(cached).toBe(initial);
} finally {
if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows);
else Reflect.deleteProperty(process.stdout, "rows");
}
});
it("keeps the cancel hint visible with tabs and a tall preview", () => {
const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 });
try {
const preview = `\`\`\`\n${Array.from({ length: 40 }, (_, index) => `line-${index}`).join("\n")}\n\`\`\``;
const component = new AskDialogComponent(
[
{ id: "q1", question: "Inspect?", options: [{ label: "Alpha", preview }], multi: true },
{ id: "q2", question: "Continue?", options: [{ label: "Bravo" }] },
],
{ onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() },
);
const out = render(component);
expect(out).toContain("PgUp/PgDn");
expect(out).toContain("Tab/←/→");
expect(out).not.toContain(" tabs");
expect(out).toContain("Ctrl+G cancel");
setKeybindings(
KeybindingsManager.inMemory({
"tui.select.cancel": "ctrl+g",
"tui.select.pageUp": "ctrl+u",
"tui.select.pageDown": "ctrl+d",
}),
);
const remapped = render(component);
expect(remapped).toContain("Ctrl+U/Ctrl+D");
expect(remapped).toContain("Ctrl+G cancel");
} finally {
if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows);
else Reflect.deleteProperty(process.stdout, "rows");
}
});
it("pages through an inline preview taller than the question viewport", () => {
const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 });
try {
const previewLines = Array.from({ length: 80 }, (_, index) => {
if (index === 0) return "PREVIEW-FIRST";
if (index === 40) return "PREVIEW-MIDDLE";
if (index === 79) return "PREVIEW-LAST";
return `preview-line-${index}`;
});
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Inspect the preview?",
options: [{ label: "Plain" }, { label: "Alpha", preview: `\`\`\`\n${previewLines.join("\n")}\n\`\`\`` }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
let out = render(component);
expect(out).toContain("PREVIEW-FIRST");
expect(out).not.toContain("PREVIEW-MIDDLE");
expect(out).not.toContain("PREVIEW-LAST");
expect(out).not.toContain("PgUp/PgDn");
expect(out).toContain("↓ scroll");
component.handleInput(DOWN);
for (let page = 0; page < 4; page++) component.handleInput(PAGE_DOWN);
out = render(component);
expect(out).toContain("PgUp/PgDn");
expect(out).toContain("PREVIEW-MIDDLE");
component.handleInput(DOWN);
out = render(component);
expect(out).toContain("Other (type your own)");
expect(out).not.toContain("PREVIEW-MIDDLE");
expect(out).not.toContain("PgUp/PgDn");
component.handleInput(UP);
out = render(component);
expect(out).toContain("PREVIEW-FIRST");
expect(out).toContain("PgUp/PgDn");
for (let page = 0; page < 10; page++) {
component.handleInput(PAGE_DOWN);
out = render(component);
}
expect(out).toContain("PREVIEW-LAST");
expect(out).not.toContain("Other (type your own)");
component.handleInput(DOWN);
out = render(component);
expect(out).toContain("Other (type your own)");
component.handleInput(UP);
out = render(component);
for (let page = 0; page < 10; page++) {
component.handleInput(PAGE_UP);
out = render(component);
}
expect(out).toContain("PREVIEW-FIRST");
} finally {
if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows);
else Reflect.deleteProperty(process.stdout, "rows");
}
});
it("keeps a selected inline preview visible when its row fits the viewport", () => {
const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 });
try {
const options = [
...Array.from({ length: 8 }, (_, index) => ({ label: `Plain ${index}` })),
{ label: "Target", preview: "```\nPREVIEW-SHORT-FIRST\npreview-short-middle\nPREVIEW-SHORT-LAST\n```" },
...Array.from({ length: 8 }, (_, index) => ({ label: `After ${index}` })),
];
const component = new AskDialogComponent([{ id: "q1", question: "Pick one?", options }], {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
for (let index = 0; index < 8; index++) component.handleInput(DOWN);
let out = render(component);
expect(out).toContain("PREVIEW-SHORT-FIRST");
expect(out).toContain("PREVIEW-SHORT-LAST");
expect(out).not.toContain("PgUp/PgDn");
expect(out).toMatch(/[↓↑↕] scroll/);
component.handleInput(PAGE_DOWN);
out = render(component);
expect(out).toContain("PREVIEW-SHORT-FIRST");
expect(out).toContain("PREVIEW-SHORT-LAST");
expect(out).not.toContain("PgUp/PgDn");
expect(out).toMatch(/[↓↑↕] scroll/);
} finally {
if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows);
else Reflect.deleteProperty(process.stdout, "rows");
}
});
it("does not repeat the tab chip in the question line", () => {
const questions: ExtensionAskDialogQuestion[] = [
{ id: "q1", question: "First question?", header: "Alpha", options: [{ label: "A" }] },
{ id: "q2", question: "Second question?", header: "Beta", options: [{ label: "B" }] },
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
const output = render(component);
// Tab bar still shows the chip…
expect(output).toContain("Alpha");
// …but the question line is just the question, not "[Alpha] First question?".
expect(output).toContain("First question?");
expect(output).not.toContain("[Alpha]");
});
it("bounds in-body question header for long multi-line questions", () => {
const onSubmit = vi.fn();
const longQuestion = "This is a very long question ".repeat(30);
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: longQuestion,
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
// The rendered body must not blow out with the full 30-line question.
// The header is capped to MAX_HEADER_ROWS lines.
const output = render(component);
// The question text should appear but be truncated — verify it does
// not contain the full repeated text (30 copies would be ~870 chars).
expect(output).toContain("This is a very long question");
// Count occurrences of the repeated phrase — should be far fewer than 30.
const matches = output.match(/This is a very long question/g);
expect(matches?.length ?? 0).toBeLessThan(10);
});
it("Other editor cancel returns to the option list without submitting", async () => {
const onPrompt = vi.fn().mockReturnValue(Promise.resolve(undefined));
const onSubmit = vi.fn();
const onCancel = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose one?",
options: [{ label: "Option A" }, { label: "Option B" }],
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel,
onPrompt,
});
// Navigate to "Other" and press Enter to open the custom input prompt.
component.handleInput(DOWN);
component.handleInput(DOWN);
component.handleInput(ENTER);
await Promise.resolve();
await Promise.resolve();
// The prompt was cancelled (returns undefined). The dialog must stay
// open — no submit, no cancel.
expect(onPrompt).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
// The dialog should still be usable: select Option A and submit.
component.handleInput("\x1b[A"); // UP to Option B
component.handleInput("\x1b[A"); // UP to Option A
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]);
});
it("keeps a fixed spawn-time height across tabs, clamped to 70% of the terminal", () => {
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Pick one?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit: vi.fn(),
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
const cap = Math.max(12, Math.floor((process.stdout.rows || 40) * 0.7));
const questionTab = component.render(80);
expect(questionTab.length).toBeLessThanOrEqual(cap);
// The submit tab renders at exactly the same height — the box is
// sized once from the tallest tab, not per-tab content.
component.handleInput(TAB);
const submitTab = component.render(80);
expect(submitTab.length).toBe(questionTab.length);
// Toggling an option (which changes the review summary) does not
// resize the box either.
component.handleInput(SHIFT_TAB);
component.handleInput(SPACE);
expect(component.render(80).length).toBe(questionTab.length);
});
it("clears the custom answer when the Other prompt is submitted empty", async () => {
const onPrompt = vi.fn();
const onSubmit = vi.fn();
const questions: ExtensionAskDialogQuestion[] = [
{
id: "q1",
question: "Choose multiple?",
options: [{ label: "Option A" }, { label: "Option B" }],
multi: true,
},
];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt,
});
// Set a custom answer via Other.
onPrompt.mockReturnValueOnce(Promise.resolve("my custom answer"));
component.handleInput(DOWN); // Option B
component.handleInput(DOWN); // Other
component.handleInput(ENTER);
await Promise.resolve();
await Promise.resolve();
expect(render(component)).toContain("my custom answer");
// Reopen Other (prefilled with the current answer) and submit an
// empty value: the custom answer is unselected.
onPrompt.mockReturnValueOnce(Promise.resolve(""));
component.handleInput(ENTER);
await Promise.resolve();
await Promise.resolve();
expect(onPrompt).toHaveBeenNthCalledWith(2, expect.any(String), "my custom answer");
expect(render(component)).not.toContain("my custom answer");
// Submitting confirms nothing was kept.
component.handleInput(TAB);
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].results[0].customInput).toBeUndefined();
});
it("normalizes malformed questions so render and submit do not crash", () => {
const onSubmit = vi.fn();
// A question entry that reaches the live dialog without a string
// `question` field (e.g. via the askDialog extension surface) used to
// throw `replaceTabs(undefined)` and take down the whole TUI.
const questions = [{ id: "q1", options: [{ label: "Option A" }] }] as unknown as ExtensionAskDialogQuestion[];
const component = new AskDialogComponent(questions, {
onSubmit,
onCancel: vi.fn(),
onPrompt: vi.fn(),
});
expect(() => render(component)).not.toThrow();
component.handleInput(ENTER);
expect(onSubmit).toHaveBeenCalledTimes(1);
const result = onSubmit.mock.calls[0][0].results[0];
expect(result.question).toBe("");
expect(result.selectedOptions).toEqual(["Option A"]);
});
});