155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { Plugin } from "@opencode/plugin/tui";
|
|
import type { Route, SlotMap } from "@opencode/plugin/tui/context";
|
|
import { testRender, type JSX } from "@opentui/solid";
|
|
import { createSignal } from "solid-js";
|
|
import { build } from "../scripts/build.js";
|
|
import type { QuotaStatus } from "../src/quota.js";
|
|
|
|
type SidebarSlotRender = (input: { readonly sessionID: string }) => JSX.Element;
|
|
type HomeSlotRender = (input: SlotMap["prompt.footer"]) => JSX.Element;
|
|
|
|
const status = (plan: string, remaining: number): QuotaStatus => ({
|
|
plan,
|
|
fetchedAt: 1,
|
|
windows: [
|
|
{
|
|
id: "codex:primary",
|
|
label: null,
|
|
seconds: 18_000,
|
|
used: 100 - remaining,
|
|
resetsAt: 2,
|
|
remaining,
|
|
},
|
|
],
|
|
});
|
|
|
|
test("built quota slot stays reactive across pending, empty, failure, update, and session states", async () => {
|
|
await build();
|
|
// Resolve the built bundle at runtime; tsc runs before build creates dist/tui.js.
|
|
const tui: typeof import("../src/tui.js") = await import(
|
|
new URL("../dist/tui.js", import.meta.url).href
|
|
);
|
|
const { default: plugin } = tui;
|
|
const pending = Promise.withResolvers<QuotaStatus | null>();
|
|
const responses: Array<() => Promise<QuotaStatus | null>> = [
|
|
async () => status("home", 90),
|
|
() => pending.promise,
|
|
];
|
|
let statusCalls = 0;
|
|
let sidebarRender: SidebarSlotRender | undefined;
|
|
let homeRender: HomeSlotRender | undefined;
|
|
let notifyUpdated: (() => void) | undefined;
|
|
const [route, setRoute] = createSignal<Route>({ type: "home" });
|
|
|
|
const quota = {
|
|
status() {
|
|
statusCalls++;
|
|
const response = responses.shift();
|
|
if (!response) throw new Error("Missing test response");
|
|
return response();
|
|
},
|
|
events: {
|
|
on(_event: "updated", callback: () => void) {
|
|
notifyUpdated = callback;
|
|
return () => undefined;
|
|
},
|
|
},
|
|
};
|
|
const contextFixture = {
|
|
client: { rpc: () => quota },
|
|
ui: {
|
|
slot(claim: {
|
|
after?: "prompt.footer";
|
|
append?: "sidebar.footer";
|
|
render: HomeSlotRender | SidebarSlotRender;
|
|
}) {
|
|
if (claim.after === "prompt.footer") {
|
|
// SAFETY: The prompt footer renderer receives the matching slot input.
|
|
homeRender = claim.render as HomeSlotRender;
|
|
} else {
|
|
// SAFETY: The only other fixture path is the session-aware sidebar slot.
|
|
sidebarRender = claim.render as SidebarSlotRender;
|
|
}
|
|
return () => undefined;
|
|
},
|
|
router: { current: route },
|
|
},
|
|
theme: { text: { default: "#ffffff", muted: "#888888" } },
|
|
};
|
|
// SAFETY: The slot setup only reads the client RPC, slot registration, and theme
|
|
// members implemented by this focused rendering fixture.
|
|
const context = Object.assign({} as Plugin.Context, contextFixture);
|
|
const cleanup = await plugin.setup(context);
|
|
if (!sidebarRender || !homeRender || !notifyUpdated)
|
|
throw new Error("Plugin did not register its slots");
|
|
|
|
const renderHome = homeRender;
|
|
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
|
|
width: 40,
|
|
height: 5,
|
|
});
|
|
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
|
const homeFrame = home.captureCharFrame();
|
|
expect(homeFrame).toContain(" ChatGPT quota (home)");
|
|
expect(homeFrame).toContain(" 5h: 90% · due");
|
|
expect(statusCalls).toBe(1);
|
|
home.renderer.destroy();
|
|
|
|
setRoute({ type: "session", sessionID: "session-one" });
|
|
const sessionPrompt = await testRender(
|
|
() =>
|
|
renderHome({
|
|
sessionID: "session-one",
|
|
mode: "normal",
|
|
showDetails: false,
|
|
}),
|
|
{ width: 40, height: 5 },
|
|
);
|
|
await sessionPrompt.renderOnce();
|
|
expect(sessionPrompt.captureCharFrame()).not.toContain("ChatGPT quota");
|
|
expect(statusCalls).toBe(1);
|
|
sessionPrompt.renderer.destroy();
|
|
|
|
const [sessionID, setSessionID] = createSignal("session-one");
|
|
const renderSlot = sidebarRender;
|
|
const setup = await testRender(
|
|
() =>
|
|
renderSlot({
|
|
get sessionID() {
|
|
return sessionID();
|
|
},
|
|
}),
|
|
{ width: 70, height: 5 },
|
|
);
|
|
|
|
try {
|
|
await setup.renderOnce();
|
|
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
|
|
|
|
pending.resolve(status("plus", 75));
|
|
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (plus)"));
|
|
expect(setup.captureCharFrame()).toContain("5h: 75% · due");
|
|
expect(setup.captureCharFrame()).not.toContain("~");
|
|
|
|
responses.push(async () => null);
|
|
notifyUpdated();
|
|
await setup.waitForFrame((frame) => !frame.includes("ChatGPT quota"));
|
|
|
|
responses.push(async () => {
|
|
throw new Error("disconnected");
|
|
});
|
|
notifyUpdated();
|
|
await setup.waitFor(() => statusCalls === 4);
|
|
expect(setup.captureCharFrame()).not.toContain("ChatGPT quota");
|
|
|
|
responses.push(async () => status("pro", 42));
|
|
setSessionID("session-two");
|
|
await setup.waitForFrame((frame) => frame.includes("ChatGPT quota (pro)"));
|
|
expect(statusCalls).toBe(5);
|
|
} finally {
|
|
setup.renderer.destroy();
|
|
if (cleanup) await cleanup();
|
|
}
|
|
});
|