repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/ephemlets/__tests__/panel.test.ts
Rawimport type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
type KeybindingsManager,
type OverlayOptions,
type TUI,
type TuiMouseEvent,
visibleWidth,
} from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import { EphemletPanel, PanelHost, panelGeometry } from "../src/panel.ts";
import { EphemletStore } from "../src/store.ts";
import type { Json } from "../src/types.ts";
const theme = { fg: (_color: string, text: string) => text } as Theme;
const keybindings = {} as KeybindingsManager;
const api = {} as ExtensionAPI;
function mouse(
x: number,
y: number,
type: TuiMouseEvent["type"] = "click",
): TuiMouseEvent {
return {
x,
y,
screenX: x,
screenY: y,
width: 60,
height: 30,
type,
button: "left",
shift: false,
alt: false,
ctrl: false,
};
}
function fixture() {
const store = new EphemletStore(() => {});
const terminal = { columns: 120, rows: 32 };
const tui = { terminal, requestRender() {} } as unknown as TUI;
const ctx = { mode: "tui", hasUI: true } as ExtensionContext;
const closed: string[] = [];
const saved: string[] = [];
let hidden = 0;
const panel = new EphemletPanel(
api,
store,
() => ctx,
tui,
theme,
keybindings,
{
changed: () => panel.refresh(),
close: (id) => {
closed.push(id);
store.delete(id);
panel.refresh();
},
save: (id) => saved.push(id),
hide: () => {
hidden++;
store.view(store.selected, false);
},
},
);
const put = (source: string, state: Json = null) => {
const record = store.put({
title: "Native UI",
purpose: "Interaction",
kind: "interactive",
source,
state,
});
store.view(record.id, true);
panel.refresh();
return record;
};
return {
store,
terminal,
tui,
panel,
put,
closed,
saved,
hidden: () => hidden,
};
}
const counter = `export default context => ({
render: width => [String(context.state?.value ?? 0)], invalidate() {},
handleInput(data) { context.setState({ value: data }); },
handleMouse(event) { context.setState({ value: event.x + ":" + event.y }); return { handled: true }; },
dispose() {}
});`;
describe("ephemlet responsive overlay", () => {
it("positions landscape right and portrait top, including small terminals", () => {
expect(panelGeometry(120, 32)).toEqual({
anchor: "top-right",
width: 54,
height: 28,
});
expect(panelGeometry(60, 50)).toEqual({
anchor: "top-left",
width: 60,
height: 24,
});
expect(panelGeometry(2, 2)).toEqual({
anchor: "top-left",
width: 2,
height: 1,
});
});
it("routes native keyboard and local mouse coordinates; fits resized content", async () => {
const f = fixture();
const record = f.put(counter, { value: "initial" });
try {
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("initial"),
);
f.panel.handleInput("x");
expect(f.store.get(record.id).state).toEqual({ value: "x" });
f.panel.handleMouse(mouse(3, 4));
expect(f.store.get(record.id).state).toEqual({ value: "2:1" });
f.terminal.columns = 30;
f.terminal.rows = 50;
const lines = f.panel.render(30);
expect(lines.length).toBeLessThanOrEqual(24);
expect(lines.every((line) => visibleWidth(line) <= 30)).toBe(true);
} finally {
f.panel.dispose();
}
});
it("switches ephemlets by keyboard/mouse and retains durable state", async () => {
const f = fixture();
const first = f.put(counter, { value: "first" });
const second = f.put(counter, { value: "second" });
try {
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("second"),
);
f.panel.handleInput("\x1b[1;3D");
expect(f.store.selected).toBe(first.id);
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("first"),
);
f.panel.handleMouse(mouse(22, 1));
expect(f.store.selected).toBe(second.id);
f.panel.handleInput("\x1b");
expect(f.hidden()).toBe(1);
expect(f.store.records.size).toBe(2);
} finally {
f.panel.dispose();
}
});
it("chrome has independent Close/Save and keyboard focus", async () => {
const f = fixture();
const record = f.put(counter, { value: "state" });
try {
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("state"),
);
f.panel.handleInput("\x13");
expect(f.saved).toEqual([record.id]);
expect(f.store.records.size).toBe(1);
f.panel.handleMouse(mouse(10, 1));
expect(f.saved).toEqual([record.id, record.id]);
f.panel.handleInput("\x1b[9;3u");
f.panel.handleInput("\r");
expect(f.closed).toEqual([record.id]);
expect(f.store.records.size).toBe(0);
} finally {
f.panel.dispose();
}
});
it("aborts and disposes native resources when source changes or closes", async () => {
const f = fixture();
const record = f.put(
'export default c => ({render: () => ["mounted"], invalidate() {}, dispose() { c.pi.disposed = c.signal.aborted; }})',
);
const scopedApi = api as unknown as { disposed?: boolean };
try {
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("mounted"),
);
f.store.delete(record.id);
f.panel.refresh();
expect(scopedApi.disposed).toBe(true);
} finally {
f.panel.dispose();
delete scopedApi.disposed;
}
});
it("retains last usable revision after factory or first-render failure", async () => {
const f = fixture();
const first = f.put(counter, { value: "usable" });
try {
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("usable"),
);
f.store.put({
...first,
expectedRevision: first.revision,
source: 'export default () => { throw new Error("bad factory"); }',
});
f.panel.refresh();
await vi.waitFor(() =>
expect(f.store.get(first.id).source).toBe(counter),
);
expect(f.store.get(first.id)).toMatchObject({
status: "failed",
state: { value: "usable" },
revision: 3,
});
await vi.waitFor(() =>
expect(f.panel.render(54).join("\n")).toContain("usable"),
);
} finally {
f.panel.dispose();
}
});
it("cleans failed widget registration and allows retry", () => {
const f = fixture();
f.put(counter);
const mounted: { render(width: number): string[] }[] = [];
const hide = vi.fn();
const notify = vi.fn();
let calls = 0;
f.tui.showOverlay = (component) => {
mounted.push(component);
return {
hide,
setHidden() {},
isHidden: () => false,
focus() {},
unfocus() {},
isFocused: () => false,
getBounds: () => undefined,
};
};
const ctx = {
mode: "tui",
ui: {
notify,
setWidget: (
_key: string,
factory: ((tui: TUI, theme: Theme) => unknown) | undefined,
) => {
if (!factory) return;
factory(f.tui, theme);
if (++calls === 1) throw new Error("Widget registration failed");
},
},
} as unknown as ExtensionContext;
const host = new PanelHost(api, f.store, () => ctx, {
changed() {},
close() {},
save() {},
hide() {},
});
try {
host.refresh();
expect(notify).toHaveBeenCalledWith(
"Ephemlet panel: Error: Widget registration failed",
"error",
);
expect(hide).toHaveBeenCalledTimes(1);
expect(mounted[0]?.render(54)).toEqual([]);
host.refresh();
expect(calls).toBe(2);
expect(mounted[1]?.render(54).length).toBeGreaterThan(0);
host.dispose();
expect(mounted[1]?.render(54)).toEqual([]);
expect(hide).toHaveBeenCalledTimes(2);
} finally {
host.dispose();
f.panel.dispose();
}
});
it("hides through overlay handle rather than deleting/reloading", async () => {
const f = fixture();
f.put(counter);
let calls = 0;
let isHidden = false;
let options: OverlayOptions | undefined;
let widget: { dispose?(): void } | undefined;
f.tui.showOverlay = (_component, nextOptions) => {
options = nextOptions;
return {
setHidden(value: boolean) {
isHidden = value;
},
isHidden: () => isHidden,
focus() {},
unfocus() {},
isFocused: () => false,
getBounds: () => undefined,
hide() {},
};
};
const ctx = {
mode: "tui",
ui: {
setWidget: (
_key: string,
factory:
| ((tui: TUI, theme: Theme) => { dispose?(): void })
| undefined,
) => {
widget?.dispose?.();
widget = undefined;
if (factory) {
calls++;
widget = factory(f.tui, theme);
}
},
},
} as unknown as ExtensionContext;
const host = new PanelHost(api, f.store, () => ctx, {
changed() {},
close() {},
save() {},
hide() {},
});
try {
host.refresh();
f.store.view(f.store.selected, false);
host.refresh();
expect(isHidden).toBe(true);
f.store.view(f.store.selected, true);
host.refresh(true);
expect(isHidden).toBe(false);
expect(calls).toBe(1);
expect(options?.anchor).toBe("top-right");
f.terminal.columns = 40;
f.terminal.rows = 50;
expect(options?.anchor).toBe("top-left");
expect(options?.width).toBe(40);
} finally {
host.dispose();
f.panel.dispose();
}
});
});