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 { jsx } from "@opentui/solid/jsx-runtime"; 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(); const responses: Array<() => Promise> = [ 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({ 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: 6, }); await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); const homeFrame = home.captureCharFrame(); expect(homeFrame.split("\n")).toEqual([ " ", " ", " ChatGPT quota (home) ", " 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(); } }); test("home quota stays out of the host footer row at wide and narrow widths", async () => { await build(); const { default: plugin } = await import(new URL("../dist/tui.js", import.meta.url).href); let homeRender: HomeSlotRender | undefined; const contextFixture = { client: { rpc: () => ({ status: async () => status("home", 90), events: { on: () => () => undefined }, }), }, ui: { slot(claim: { after?: "prompt.footer"; render: HomeSlotRender }) { if (claim.after === "prompt.footer") homeRender = claim.render; return () => undefined; }, router: { current: () => ({ type: "home" as const }) }, }, theme: { text: { default: "#ffffff", muted: "#888888" } }, }; // SAFETY: This focused fixture implements every context member read by slot setup/rendering. const cleanup = await plugin.setup(Object.assign({} as Plugin.Context, contextFixture)); if (!homeRender) throw new Error("Plugin did not register the home slot"); const renderHome = homeRender; try { for (const width of [75, 40]) { const footer = (quota: boolean) => jsx("box", { flexDirection: "row", children: [ jsx("text", { children: "/project" }), jsx("box", { flexGrow: 1 }), jsx("text", { children: "NORMAL" }), jsx("text", { children: " ctrl+k commands" }), quota ? renderHome({ mode: "normal", showDetails: false }) : undefined, ], }); const baseline = await testRender(() => footer(false), { width, height: 6 }); const rendered = await testRender(() => footer(true), { width, height: 6 }); try { await baseline.renderOnce(); await rendered.waitForFrame((frame) => frame.includes("ChatGPT quota (home)")); const baselineRows = baseline.captureCharFrame().split("\n"); const quotaRows = rendered.captureCharFrame().split("\n"); expect(quotaRows[0]).toBe(baselineRows[0]); expect(quotaRows[0]).toContain("/project"); expect(quotaRows[0]).toContain("NORMAL ctrl+k commands"); expect(quotaRows[1]?.trim()).toBe(""); expect(quotaRows[2]?.trim()).toBe("ChatGPT quota (home)"); expect(quotaRows[3]?.trim()).toBe("5h: 90% · due"); expect(quotaRows[2]?.indexOf("ChatGPT quota (home)")).toBe( Math.ceil((width - "ChatGPT quota (home)".length) / 2), ); } finally { baseline.renderer.destroy(); rendered.renderer.destroy(); } } } finally { if (cleanup) await cleanup(); } });