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(); 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(); } });