416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
import { runOnboardingSetup } from "@oh-my-pi/pi-coding-agent/commands/setup";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { SETTINGS_SCHEMA } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
|
|
import {
|
|
ALL_SCENES,
|
|
CURRENT_SETUP_VERSION,
|
|
markSetupWizardComplete,
|
|
runSetupWizard,
|
|
type SetupScene,
|
|
type SetupSceneHost,
|
|
selectSetupScenes,
|
|
} from "@oh-my-pi/pi-coding-agent/modes/setup-wizard";
|
|
import { WebSearchTab } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/scenes/web-search";
|
|
import { SetupWizardComponent } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/wizard-overlay";
|
|
import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "@oh-my-pi/pi-coding-agent/web/search/types";
|
|
|
|
function fakeContextWithConfiguredModel(): InteractiveModeContext {
|
|
return {
|
|
session: {
|
|
modelRegistry: {
|
|
getAvailable: () => [{ provider: "configured", id: "model" }],
|
|
},
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
}
|
|
|
|
function testScene(id: string, minVersion: number, shouldRun?: () => boolean): SetupScene {
|
|
return {
|
|
id,
|
|
title: id,
|
|
minVersion,
|
|
shouldRun,
|
|
mount: () => ({
|
|
title: id,
|
|
render: () => [],
|
|
invalidate: () => {},
|
|
}),
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await initTheme(false, "unicode", false, "titanium", "light");
|
|
});
|
|
|
|
describe("setup wizard scene selection", () => {
|
|
it("runs all v1 scenes for a new user", async () => {
|
|
const scenes = await selectSetupScenes(0, ALL_SCENES, fakeContextWithConfiguredModel(), { isTTY: true });
|
|
expect(scenes.map(scene => scene.id)).toEqual(ALL_SCENES.map(scene => scene.id));
|
|
});
|
|
|
|
it("keeps CURRENT_SETUP_VERSION in sync with the highest scene minVersion", () => {
|
|
// main.ts's cold-launch gate sources CURRENT_SETUP_VERSION from the tiny
|
|
// `setup-version` module to decide whether to load the wizard at all. If a
|
|
// new scene raises the bar but the constant is not bumped, stale installs
|
|
// would never see the scene. Guard the invariant the gate relies on.
|
|
const highestMinVersion = Math.max(...ALL_SCENES.map(scene => scene.minVersion));
|
|
expect(CURRENT_SETUP_VERSION).toBe(highestMinVersion);
|
|
});
|
|
|
|
it("runs only scenes newer than the stored setup version", async () => {
|
|
const scenes = [testScene("v1-a", 1), testScene("v1-b", 1), testScene("v2", 2)];
|
|
const selected = await selectSetupScenes(1, scenes, fakeContextWithConfiguredModel(), { isTTY: true });
|
|
expect(selected.map(scene => scene.id)).toEqual(["v2"]);
|
|
});
|
|
|
|
it("runs no scenes at the current setup version", async () => {
|
|
const scenes = await selectSetupScenes(CURRENT_SETUP_VERSION, ALL_SCENES, fakeContextWithConfiguredModel(), {
|
|
isTTY: true,
|
|
});
|
|
expect(scenes).toEqual([]);
|
|
});
|
|
|
|
it("honors hard environment gates", async () => {
|
|
const ctx = fakeContextWithConfiguredModel();
|
|
expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: false })).toEqual([]);
|
|
expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: true, resuming: true })).toEqual([]);
|
|
expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: true, skipEnv: "1" })).toEqual([]);
|
|
expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: true, setupWizardEnabled: false })).toEqual([]);
|
|
});
|
|
|
|
it("keeps the providers scene eligible even when a model is already configured", async () => {
|
|
const scenes = await selectSetupScenes(0, ALL_SCENES, fakeContextWithConfiguredModel(), { isTTY: true });
|
|
expect(scenes.some(scene => scene.id === "providers")).toBe(true);
|
|
});
|
|
|
|
it("force mode ignores version and user skip gates but still requires a TTY", async () => {
|
|
const ctx = fakeContextWithConfiguredModel();
|
|
const selected = await selectSetupScenes(CURRENT_SETUP_VERSION, ALL_SCENES, ctx, {
|
|
isTTY: true,
|
|
setupWizardEnabled: false,
|
|
skipEnv: "1",
|
|
resuming: true,
|
|
force: true,
|
|
});
|
|
expect(selected.map(scene => scene.id)).toEqual(ALL_SCENES.map(scene => scene.id));
|
|
expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: false, force: true })).toEqual([]);
|
|
});
|
|
|
|
it("applies scene shouldRun only as a hard environment gate", async () => {
|
|
const selected = await selectSetupScenes(
|
|
0,
|
|
[testScene("blocked", 1, () => false), testScene("allowed", 1, () => true)],
|
|
fakeContextWithConfiguredModel(),
|
|
{ isTTY: true },
|
|
);
|
|
expect(selected.map(scene => scene.id)).toEqual(["allowed"]);
|
|
});
|
|
});
|
|
|
|
describe("setup wizard persistence", () => {
|
|
it("marks the current setup version complete", async () => {
|
|
const settings = Settings.isolated();
|
|
await markSetupWizardComplete(settings);
|
|
expect(settings.get("setupVersion")).toBe(CURRENT_SETUP_VERSION);
|
|
});
|
|
|
|
it("can run a targeted scene without setup-version or welcome-intro side effects", async () => {
|
|
const settings = Settings.isolated({ setupVersion: 0 });
|
|
const hideOverlay = mock(() => {});
|
|
const setFocus = mock((_component: unknown) => {});
|
|
const requestRender = mock(() => {});
|
|
const playWelcomeIntro = mock(() => {});
|
|
let component: SetupWizardComponent | undefined;
|
|
const scene: SetupScene = {
|
|
id: "providers",
|
|
title: "providers",
|
|
minVersion: 1,
|
|
mount: host => ({
|
|
title: "providers",
|
|
onMount: () => host.finish("done"),
|
|
render: () => [],
|
|
invalidate: () => {},
|
|
}),
|
|
};
|
|
const ctx = {
|
|
settings,
|
|
playWelcomeIntro,
|
|
ui: {
|
|
terminal: { rows: 24 },
|
|
showOverlay: (nextComponent: SetupWizardComponent) => {
|
|
component = nextComponent;
|
|
return { hide: hideOverlay };
|
|
},
|
|
setFocus,
|
|
requestRender,
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
const pending = runSetupWizard(ctx, [scene], { markComplete: false, playWelcomeIntro: false });
|
|
component?.handleInput?.("\n");
|
|
component?.handleInput?.("\n");
|
|
await pending;
|
|
|
|
expect(settings.get("setupVersion")).toBe(0);
|
|
expect(playWelcomeIntro).not.toHaveBeenCalled();
|
|
expect(hideOverlay).toHaveBeenCalledTimes(1);
|
|
expect(setFocus).toHaveBeenCalled();
|
|
});
|
|
});
|
|
describe("setup wizard mouse routing", () => {
|
|
it("synthesizes arrow keys from wheel notches for scenes without routeMouse", () => {
|
|
const received: string[] = [];
|
|
const scene: SetupScene = {
|
|
id: "scrollable",
|
|
title: "scrollable",
|
|
minVersion: 1,
|
|
mount: () => ({
|
|
title: "scrollable",
|
|
handleInput: (data: string) => received.push(data),
|
|
render: () => [],
|
|
invalidate: () => {},
|
|
}),
|
|
};
|
|
const ctx = {
|
|
settings: Settings.isolated(),
|
|
ui: {
|
|
terminal: { rows: 24 },
|
|
setFocus: () => {},
|
|
requestRender: () => {},
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
const component = new SetupWizardComponent(ctx, [scene]);
|
|
try {
|
|
void component.run();
|
|
// Left click during the splash advances into the scene, like Enter.
|
|
component.handleInput("\x1b[<0;5;5M");
|
|
component.handleInput("\x1b[<64;10;5M"); // wheel up
|
|
component.handleInput("\x1b[<65;10;5M"); // wheel down
|
|
component.handleInput("\x1b[<35;10;5M"); // pointer motion — swallowed
|
|
component.handleInput("\x1b[<0;10;5M"); // click in scene — swallowed
|
|
expect(received).toEqual(["\x1b[A", "\x1b[B"]);
|
|
} finally {
|
|
component.dispose();
|
|
}
|
|
});
|
|
|
|
it("routes hit-tested mouse events at scene-local coordinates to scenes with routeMouse", async () => {
|
|
await initTheme(false, "unicode", false, "titanium", "light");
|
|
const routed: { kind: string; line: number; col: number }[] = [];
|
|
const keys: string[] = [];
|
|
const scene: SetupScene = {
|
|
id: "mouse",
|
|
title: "mouse",
|
|
minVersion: 1,
|
|
mount: () => ({
|
|
title: "mouse",
|
|
handleInput: (data: string) => keys.push(data),
|
|
routeMouse: (event, line, col) => {
|
|
const kind =
|
|
event.wheel !== null
|
|
? `wheel:${event.wheel}`
|
|
: event.motion
|
|
? "motion"
|
|
: event.leftClick
|
|
? "click"
|
|
: "other";
|
|
routed.push({ kind, line, col });
|
|
},
|
|
render: () => ["MARKER-ROW"],
|
|
invalidate: () => {},
|
|
}),
|
|
};
|
|
const ctx = {
|
|
settings: Settings.isolated(),
|
|
ui: {
|
|
terminal: { rows: 24 },
|
|
setFocus: () => {},
|
|
requestRender: () => {},
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
const component = new SetupWizardComponent(ctx, [scene]);
|
|
try {
|
|
void component.run();
|
|
component.handleInput("\r"); // splash → scene
|
|
await Bun.sleep(500); // let the splash→scene dissolve (420ms) finish so the frame is the scene
|
|
const frame = component.render(80);
|
|
const row = frame.findIndex(line => line.includes("MARKER-ROW"));
|
|
expect(row).toBeGreaterThan(0);
|
|
const indent = /^ */.exec(frame[row])?.[0].length ?? 0;
|
|
expect(indent).toBeGreaterThan(0);
|
|
// SGR reports are 1-based; two columns into the marker text.
|
|
component.handleInput(`\x1b[<35;${indent + 3};${row + 1}M`);
|
|
component.handleInput(`\x1b[<0;${indent + 3};${row + 1}M`);
|
|
component.handleInput("\x1b[<64;1;1M"); // wheel forwards regardless of pointer position
|
|
expect(routed.slice(0, 2)).toEqual([
|
|
{ kind: "motion", line: 0, col: 2 },
|
|
{ kind: "click", line: 0, col: 2 },
|
|
]);
|
|
expect(routed[2]?.kind).toBe("wheel:-1");
|
|
// routeMouse scenes get no synthesized arrows and no raw SGR bytes.
|
|
expect(keys).toEqual([]);
|
|
} finally {
|
|
component.dispose();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("setup wizard theme previews", () => {
|
|
it("restores the selected glyph preset after previewing ANSI-safe mode", async () => {
|
|
await initTheme(false, "nerd", false, "titanium", "light");
|
|
const settings = Settings.isolated({ symbolPreset: "nerd", colorBlindMode: false });
|
|
const setupScene = ALL_SCENES.find(scene => scene.id === "theme");
|
|
expect(setupScene).toBeDefined();
|
|
|
|
const host = {
|
|
ctx: {
|
|
settings,
|
|
ui: {
|
|
invalidate: () => {},
|
|
requestRender: () => {},
|
|
},
|
|
},
|
|
requestRender: () => {},
|
|
finish: () => {},
|
|
setFocus: () => {},
|
|
restoreFocus: () => {},
|
|
} as unknown as SetupSceneHost;
|
|
|
|
const controller = setupScene!.mount(host);
|
|
controller.handleInput?.("5");
|
|
await Bun.sleep(20);
|
|
expect(theme.getSymbolPreset()).toBe("ascii");
|
|
|
|
controller.handleInput?.("2");
|
|
await Bun.sleep(20);
|
|
expect(settings.get("symbolPreset")).toBe("nerd");
|
|
expect(theme.getSymbolPreset()).toBe("nerd");
|
|
});
|
|
});
|
|
|
|
describe("setup wizard glyph scene", () => {
|
|
it("lists Nerd Font first and commits the chosen preset", async () => {
|
|
await initTheme(false, "unicode", false, "titanium", "light");
|
|
const settings = Settings.isolated();
|
|
const scene = ALL_SCENES.find(s => s.id === "glyph-mode");
|
|
expect(scene).toBeDefined();
|
|
|
|
let finished = false;
|
|
const host = {
|
|
ctx: {
|
|
settings,
|
|
ui: { invalidate: () => {}, requestRender: () => {} },
|
|
},
|
|
requestRender: () => {},
|
|
finish: () => {
|
|
finished = true;
|
|
},
|
|
setFocus: () => {},
|
|
restoreFocus: () => {},
|
|
} as unknown as SetupSceneHost;
|
|
|
|
const controller = scene!.mount(host);
|
|
// Row "1" is now Nerd Font (it must lead the list).
|
|
controller.handleInput?.("1");
|
|
await Bun.sleep(20);
|
|
expect(theme.getSymbolPreset()).toBe("nerd");
|
|
|
|
controller.handleInput?.("\n");
|
|
await Bun.sleep(20);
|
|
expect(settings.get("symbolPreset")).toBe("nerd");
|
|
expect(finished).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("setup wizard web search tab", () => {
|
|
it("exposes every web-search provider preference in the schema-backed TUI list", () => {
|
|
const schema = SETTINGS_SCHEMA["providers.webSearch"];
|
|
expect(schema.values).toEqual(SEARCH_PROVIDER_PREFERENCES);
|
|
expect(schema.ui.options).toEqual(SEARCH_PROVIDER_OPTIONS);
|
|
});
|
|
|
|
it("persists the highlighted provider as the web search preference", async () => {
|
|
const settings = Settings.isolated();
|
|
const host = {
|
|
ctx: {
|
|
settings,
|
|
session: { modelRegistry: { authStorage: { hasAuth: () => false } } },
|
|
},
|
|
requestRender: () => {},
|
|
finish: () => {},
|
|
setFocus: () => {},
|
|
restoreFocus: () => {},
|
|
} as unknown as SetupSceneHost;
|
|
|
|
const tab = new WebSearchTab(host);
|
|
tab.handleInput("\x1b[B"); // move off "auto" to the next provider
|
|
tab.handleInput("\n"); // confirm the highlighted provider
|
|
await Bun.sleep(20);
|
|
|
|
const expected = SETTINGS_SCHEMA["providers.webSearch"].ui.options[1].value;
|
|
expect(expected).not.toBe("auto");
|
|
expect(settings.get("providers.webSearch")).toBe(expected);
|
|
});
|
|
|
|
it("can select the last provider in the setup TUI list", async () => {
|
|
const settings = Settings.isolated();
|
|
const host = {
|
|
ctx: {
|
|
settings,
|
|
session: { modelRegistry: { authStorage: { hasAuth: () => false } } },
|
|
},
|
|
requestRender: () => {},
|
|
finish: () => {},
|
|
setFocus: () => {},
|
|
restoreFocus: () => {},
|
|
} as unknown as SetupSceneHost;
|
|
|
|
const tab = new WebSearchTab(host);
|
|
for (let i = 1; i < SEARCH_PROVIDER_OPTIONS.length; i++) {
|
|
tab.handleInput("\x1b[B");
|
|
}
|
|
tab.handleInput("\n");
|
|
await Bun.sleep(20);
|
|
|
|
const lastOption = SEARCH_PROVIDER_OPTIONS[SEARCH_PROVIDER_OPTIONS.length - 1]!;
|
|
expect(settings.get("providers.webSearch")).toBe(lastOption.value);
|
|
});
|
|
});
|
|
|
|
describe("omp setup onboarding trigger", () => {
|
|
it("starts the normal interactive command with forced setup wizard", async () => {
|
|
let forceSetupWizard: boolean | undefined;
|
|
await runOnboardingSetup({
|
|
stdinIsTTY: true,
|
|
stdoutIsTTY: true,
|
|
runRoot: async (_parsed, _rawArgs, deps) => {
|
|
forceSetupWizard = deps?.forceSetupWizard;
|
|
},
|
|
});
|
|
expect(forceSetupWizard).toBe(true);
|
|
});
|
|
|
|
it("rejects onboarding setup without an interactive TTY", async () => {
|
|
let stderr = "";
|
|
let exitCode: number | undefined;
|
|
await expect(
|
|
runOnboardingSetup({
|
|
stdinIsTTY: false,
|
|
stdoutIsTTY: true,
|
|
writeStderr: text => {
|
|
stderr += text;
|
|
},
|
|
exit: code => {
|
|
exitCode = code;
|
|
throw new Error("exit");
|
|
},
|
|
}),
|
|
).rejects.toThrow("exit");
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("interactive TTY");
|
|
});
|
|
});
|