diff --git a/package.json b/package.json index 91808d7..520a5c8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "LICENSE" ], "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test --preload @opentui/solid/preload tests" }, "keywords": [ "opencode", diff --git a/src/refresh.ts b/src/refresh.ts new file mode 100644 index 0000000..087ee65 --- /dev/null +++ b/src/refresh.ts @@ -0,0 +1,77 @@ +/** + * Refresh cycle for one usage poll. + * + * A cycle is bounded by `timeoutMs`, which covers the whole load, credential + * lookup included. A cycle that is still in flight makes the next poll a no-op, + * and a result that arrives after disposal is dropped. The deadline does not + * cancel the load: it only stops the cycle from waiting for it. + */ + +export type RefreshOptions = { + /** Runs one load. Resolves with the value, or rejects. */ + load: () => Promise + /** Upper bound for one cycle, in milliseconds. */ + timeoutMs: number + /** Receives the value of a cycle that finished in time, while live. */ + onValue: (value: Value) => void + /** Receives a user-facing message, while live. */ + onError: (message: string) => void +} + +export type Refresher = { + /** Runs one cycle, unless a cycle is already in flight or the refresher is disposed. */ + refresh: () => Promise + /** Drops later results. Does not cancel an in-flight load. */ + dispose: () => void +} + +/** + * Race a promise against a deadline. Resolves `undefined` when the deadline + * wins, so the caller can tell a timeout apart from a real result. + */ +async function withDeadline(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms) + }) + + try { + return await Promise.race([promise, deadline]) + } finally { + clearTimeout(timer) + } +} + +export function createRefresher(options: RefreshOptions): Refresher { + let disposed = false + let refreshing = false + + const refresh = async () => { + if (disposed || refreshing) return + refreshing = true + + try { + const value = await withDeadline(options.load(), options.timeoutMs) + if (disposed) return + + if (value === undefined) { + options.onError("request timed out") + return + } + + options.onValue(value) + } catch { + if (disposed) return + options.onError("unexpected error") + } finally { + refreshing = false + } + } + + return { + refresh, + dispose: () => { + disposed = true + }, + } +} diff --git a/tests/lifecycle.test.tsx b/tests/lifecycle.test.tsx new file mode 100644 index 0000000..e6515db --- /dev/null +++ b/tests/lifecycle.test.tsx @@ -0,0 +1,266 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Lifecycle reproduction against the real plugin setup and a fake host. + * + * The fake host records the slot claim, so a test can mount the claim's render + * function, dispose it, and mount the same claim again. The real OpenTUI test + * renderer provides the Solid root for each mount. + */ +import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import type { Context } from "@opencode/plugin/tui" +import { testRender } from "@opentui/solid" +import * as usageModule from "../src/usage.ts" +import type { UsageResult } from "../src/usage.ts" + +const POLL_MS = 60_000 +const CLOCK_MS = 30_000 + +type Deferred = { + promise: Promise + resolve: (value: Value) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void + let reject!: (error: unknown) => void + const promise = new Promise((onValue, onError) => { + resolve = onValue + reject = onError + }) + + return { promise, resolve, reject } +} + +type Scheduled = { id: ReturnType; run: () => void; ms: number } + +const loads: Deferred[] = [] + +mock.module("../src/usage.ts", () => ({ + ...usageModule, + fetchUsage: () => { + const load = deferred() + loads.push(load) + return load.promise + }, +})) + +afterAll(() => { + mock.restore() +}) + +const { default: plugin } = await import("../tui.tsx") + +type Claim = { render: (input: { sessionID: string }) => unknown } + +function fakeHost() { + const claims: Claim[] = [] + let released = 0 + const context = { + theme: {}, + ui: { + slot: (claim: Claim) => { + claims.push(claim) + return () => { + released += 1 + } + }, + }, + } as unknown as Context + + return { + context, + claims, + released: () => released, + } +} + +async function startPlugin() { + const host = fakeHost() + const cleanup = await plugin.setup(host.context) + + return { ...host, cleanup: cleanup as (() => void) | undefined } +} + +async function mount(claim: Claim) { + const view = await testRender(() => claim.render({ sessionID: "session" }) as any, { width: 60, height: 10 }) + await view.flush() + return view +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function ready(percent: number): UsageResult { + return { + ok: true, + usage: { + rolling: { percent, resetsAt: new Date(Date.now() + 3_600_000).toISOString() }, + weekly: { percent: 10 }, + monthly: { percent: 5 }, + }, + } +} + +let schedules: Scheduled[] = [] +let cleared: Array> = [] +let intervalSpy: ReturnType +let clearSpy: ReturnType +let dateSpy: ReturnType | undefined + +const realSetInterval = globalThis.setInterval +const realClearInterval = globalThis.clearInterval + +beforeEach(() => { + loads.length = 0 + schedules = [] + cleared = [] + + intervalSpy = spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, ms?: number) => { + const id = realSetInterval(callback, ms) + schedules.push({ id, run: callback, ms: Number(ms) }) + return id + }) as typeof setInterval) + + clearSpy = spyOn(globalThis, "clearInterval").mockImplementation(((id: ReturnType) => { + cleared.push(id) + realClearInterval(id) + }) as typeof clearInterval) +}) + +afterEach(() => { + for (const entry of schedules) realClearInterval(entry.id) + dateSpy?.mockRestore() + dateSpy = undefined + clearSpy.mockRestore() + intervalSpy.mockRestore() +}) + +const polls = () => schedules.filter((entry) => entry.ms === POLL_MS) +const clocks = () => schedules.filter((entry) => entry.ms === CLOCK_MS) + +describe("panel lifecycle", () => { + test("keeps ready state and the in-flight cycle across a panel remount", async () => { + const host = await startPlugin() + expect(host.claims).toHaveLength(1) + + const first = await mount(host.claims[0]!) + expect(loads, "one load after the first panel mount").toHaveLength(1) + expect(first.captureCharFrame()).toContain("\u2026") + await first.dispose() + + expect(loads).toHaveLength(1) + + const second = await mount(host.claims[0]!) + expect(second.captureCharFrame()).toContain("\u2026") + expect(loads, "the panel remount must reuse the in-flight load").toHaveLength(1) + + loads[0]!.resolve(ready(42)) + await settle() + await second.flush() + + const frame = second.captureCharFrame() + expect(frame).toContain("42%") + expect(frame).toContain("10%") + expect(loads).toHaveLength(1) + + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + await second.dispose() + }) + + test("keeps error state across a panel remount without a new load", async () => { + const host = await startPlugin() + const first = await mount(host.claims[0]!) + + loads[0]!.resolve({ ok: false, error: "rate limited" }) + await settle() + await first.flush() + expect(first.captureCharFrame(), "error visible before the panel remount").toContain("rate limited") + + await first.dispose() + + const second = await mount(host.claims[0]!) + const frame = second.captureCharFrame() + expect(frame, "error retained after the panel remount").toContain("rate limited") + expect(loads).toHaveLength(1) + + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + await second.dispose() + }) + + test("keeps ready state across a remount and follows the setup-owned clock", async () => { + const host = await startPlugin() + const first = await mount(host.claims[0]!) + const base = Date.parse("2030-01-01T00:00:00Z") + + dateSpy = spyOn(Date, "now").mockReturnValue(base) + loads[0]!.resolve({ + ok: true, + usage: { + rolling: { percent: 42, resetsAt: new Date(base + 3_600_000).toISOString() }, + weekly: { percent: 10 }, + monthly: { percent: 5 }, + }, + }) + await settle() + await first.flush() + clocks()[0]!.run() + await settle() + await first.flush() + expect(first.captureCharFrame(), "usage visible before the panel remount").toContain("42%") + expect(first.captureCharFrame()).toContain("1h 0m") + await first.dispose() + + const second = await mount(host.claims[0]!) + expect(second.captureCharFrame(), "usage retained after the panel remount").toContain("42%") + expect(loads).toHaveLength(1) + expect(second.captureCharFrame()).toContain("1h 0m") + + dateSpy.mockReturnValue(base + 1_800_000) + clocks()[0]!.run() + await settle() + await second.flush() + expect(second.captureCharFrame()).toContain("30m") + + dateSpy.mockRestore() + dateSpy = undefined + host.cleanup?.() + await second.dispose() + }) + + test("cleanup stops timers, drops a late write and releases the slot", async () => { + const host = await startPlugin() + const view = await mount(host.claims[0]!) + expect(loads).toHaveLength(1) + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + + expect(host.released()).toBe(1) + expect(cleared).toContain(polls()[0]!.id) + expect(cleared).toContain(clocks()[0]!.id) + + // A stale poll callback must not start new work after cleanup. + polls()[0]!.run() + await settle() + expect(loads).toHaveLength(1) + + loads[0]!.resolve(ready(42)) + await settle() + await view.flush() + + const frame = view.captureCharFrame() + expect(frame).toContain("\u2026") + expect(frame).not.toContain("42%") + + await view.dispose() + }) +}) diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts new file mode 100644 index 0000000..5c05d82 --- /dev/null +++ b/tests/refresh.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test" +import { createRefresher, type RefreshOptions } from "../src/refresh.ts" + +type Deferred = { + promise: Promise + resolve: (value: Value) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void + let reject!: (error: unknown) => void + const promise = new Promise((onValue, onError) => { + resolve = onValue + reject = onError + }) + + return { promise, resolve, reject } +} + +/** Records what a refresher reported, in order. */ +function recorder() { + const values: string[] = [] + const errors: string[] = [] + + return { values, errors } +} + +function refresherWith(load: () => Promise, timeoutMs: number, seen: ReturnType) { + const options: RefreshOptions = { + load, + timeoutMs, + onValue: (value) => seen.values.push(value), + onError: (message) => seen.errors.push(message), + } + + return createRefresher(options) +} + +describe("createRefresher", () => { + test("reports the value of a cycle that finishes in time", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + load.resolve("usage") + await cycle + + expect(seen.values).toEqual(["usage"]) + expect(seen.errors).toEqual([]) + }) + + test("reports a timeout when the cycle outlives the deadline", async () => { + const seen = recorder() + const refresher = refresherWith(() => new Promise(() => {}), 5, seen) + + await refresher.refresh() + + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual([]) + }) + + test("drops a value that arrives after the deadline", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 5, seen) + + await refresher.refresh() + load.resolve("late") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual(["request timed out"]) + }) + + test("keeps the load running after the deadline and starts a new cycle", async () => { + const first = deferred() + const seen = recorder() + let loads = 0 + const refresher = refresherWith( + () => { + loads += 1 + return loads === 1 ? first.promise : Promise.resolve("second") + }, + 5, + seen, + ) + + await refresher.refresh() + await refresher.refresh() + + // The timed-out load was not cancelled, and its late value is dropped. + first.resolve("late") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(loads).toBe(2) + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual(["second"]) + }) + + test("starts a new cycle after a timeout", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith( + () => { + loads += 1 + return loads === 1 ? new Promise(() => {}) : Promise.resolve("second") + }, + 5, + seen, + ) + + await refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(2) + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual(["second"]) + }) + + test("keeps a second call from overlapping the cycle in flight", async () => { + const load = deferred() + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return load.promise + }, 1_000, seen) + + const cycle = refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(1) + + load.resolve("usage") + await cycle + expect(seen.values).toEqual(["usage"]) + }) + + test("starts a new cycle once the previous one settles", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return Promise.resolve(`usage ${loads}`) + }, 1_000, seen) + + await refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(2) + expect(seen.values).toEqual(["usage 1", "usage 2"]) + }) + + test("reports an unexpected error when the load rejects", async () => { + const seen = recorder() + const refresher = refresherWith(() => Promise.reject(new Error("boom")), 1_000, seen) + + await refresher.refresh() + + expect(seen.errors).toEqual(["unexpected error"]) + expect(seen.values).toEqual([]) + }) + + test("does not start a load after disposal", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return Promise.resolve("usage") + }, 1_000, seen) + + refresher.dispose() + await refresher.refresh() + + expect(loads).toBe(0) + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) + + test("drops a value that arrives after disposal", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + refresher.dispose() + load.resolve("usage") + await cycle + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) + + test("drops an error that arrives after disposal", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + refresher.dispose() + load.reject(new Error("boom")) + await cycle + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) +}) diff --git a/tui.tsx b/tui.tsx index 6f73f75..ed8182e 100644 --- a/tui.tsx +++ b/tui.tsx @@ -3,11 +3,16 @@ * TUI entry: renders the OpenCode Go usage windows in the sidebar footer. * * See ../README.md for installation and behaviour details. + * + * State lives in `setup`, not in the component: unmounting and remounting a + * slot panel must keep the current usage state and the refresh cycle alive. + * The panel reads the signals through accessors. */ import { Plugin } from "@opencode/plugin/tui" import type { RGBA } from "@opentui/core" -import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { createSignal, Show } from "solid-js" import { bar, remaining, severity, type Severity } from "./src/format.ts" +import { createRefresher } from "./src/refresh.ts" import { clampPercent, fetchUsage, type Usage, type UsageWindow, type UsageWindowKind } from "./src/usage.ts" const POLL_MS = 60_000 @@ -16,23 +21,6 @@ const CLOCK_MS = 30_000 /** Upper bound for a whole refresh cycle, credential lookup included. */ const REFRESH_TIMEOUT_MS = 15_000 -/** - * Race a promise against a deadline. Resolves `undefined` when the deadline - * wins, so the caller can tell a timeout apart from a real result. - */ -async function withDeadline(promise: Promise, ms: number): Promise { - let timer: ReturnType | undefined - const deadline = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), ms) - }) - - try { - return await Promise.race([promise, deadline]) - } finally { - clearTimeout(timer) - } -} - const ROWS: { key: UsageWindowKind; label: string }[] = [ { key: "rolling", label: "5h" }, { key: "weekly", label: "7d" }, @@ -94,56 +82,18 @@ const Row = (props: { label: string; window: UsageWindow; now: number; palette: type Status = { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; usage: Usage } -const Panel = (props: { theme: unknown }) => { - const [status, setStatus] = createSignal({ kind: "loading" }) - const [now, setNow] = createSignal(Date.now()) +const Panel = (props: { theme: unknown; status: () => Status; now: () => number }) => { const palette = () => paletteOf(props.theme) - let disposed = false - let refreshing = false - - const refresh = async () => { - if (refreshing) return - refreshing = true - - try { - const result = await withDeadline(fetchUsage(), REFRESH_TIMEOUT_MS) - if (disposed) return - - if (!result) { - setStatus({ kind: "error", message: "request timed out" }) - return - } - - setStatus(result.ok ? { kind: "ready", usage: result.usage } : { kind: "error", message: result.error }) - } catch { - if (disposed) return - setStatus({ kind: "error", message: "unexpected error" }) - } finally { - refreshing = false - } - } - - onMount(() => { - refresh() - const poll = setInterval(refresh, POLL_MS) - const clock = setInterval(() => setNow(Date.now()), CLOCK_MS) - onCleanup(() => { - disposed = true - clearInterval(poll) - clearInterval(clock) - }) - }) - return ( Go usage - + {"\u2026"} { - const current = status() + const current = props.status() return current.kind === "error" ? current : undefined })()} keyed @@ -152,7 +102,7 @@ const Panel = (props: { theme: unknown }) => { { - const current = status() + const current = props.status() return current.kind === "ready" ? current.usage : undefined })()} keyed @@ -160,7 +110,7 @@ const Panel = (props: { theme: unknown }) => { {(usage) => ( {ROWS.map((row) => ( - + ))} )} @@ -172,9 +122,31 @@ const Panel = (props: { theme: unknown }) => { export default Plugin.define({ id: "go-usage-meter", setup(context) { - return context.ui.slot({ + const [status, setStatus] = createSignal({ kind: "loading" }) + const [now, setNow] = createSignal(Date.now()) + + const refresher = createRefresher({ + load: fetchUsage, + timeoutMs: REFRESH_TIMEOUT_MS, + onValue: (result) => + setStatus(result.ok ? { kind: "ready", usage: result.usage } : { kind: "error", message: result.error }), + onError: (message) => setStatus({ kind: "error", message }), + }) + + refresher.refresh() + const poll = setInterval(refresher.refresh, POLL_MS) + const clock = setInterval(() => setNow(Date.now()), CLOCK_MS) + + const release = context.ui.slot({ append: "sidebar.footer", - render: () => , + render: () => , }) + + return () => { + refresher.dispose() + clearInterval(poll) + clearInterval(clock) + release() + } }, })