Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"LICENSE"
],
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "bun test --preload @opentui/solid/preload tests"
},
"keywords": [
"opencode",
Expand Down
77 changes: 77 additions & 0 deletions src/refresh.ts
Original file line number Diff line number Diff line change
@@ -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<Value> = {
/** Runs one load. Resolves with the value, or rejects. */
load: () => Promise<Value>
/** 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<void>
/** 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<Value>(promise: Promise<Value>, ms: number): Promise<Value | undefined> {
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), ms)
})

try {
return await Promise.race([promise, deadline])
} finally {
clearTimeout(timer)
}
}

export function createRefresher<Value>(options: RefreshOptions<Value>): 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
},
}
}
266 changes: 266 additions & 0 deletions tests/lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Value> = {
promise: Promise<Value>
resolve: (value: Value) => void
reject: (error: unknown) => void
}

function deferred<Value>(): Deferred<Value> {
let resolve!: (value: Value) => void
let reject!: (error: unknown) => void
const promise = new Promise<Value>((onValue, onError) => {
resolve = onValue
reject = onError
})

return { promise, resolve, reject }
}

type Scheduled = { id: ReturnType<typeof setInterval>; run: () => void; ms: number }

const loads: Deferred<UsageResult>[] = []

mock.module("../src/usage.ts", () => ({
...usageModule,
fetchUsage: () => {
const load = deferred<UsageResult>()
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<ReturnType<typeof setInterval>> = []
let intervalSpy: ReturnType<typeof spyOn>
let clearSpy: ReturnType<typeof spyOn>
let dateSpy: ReturnType<typeof spyOn> | 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<typeof setInterval>) => {
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()
})
})
Loading