From e6d20440f9cc851e059010ef203bc0d8e70e95de Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 10:53:39 -0400 Subject: [PATCH] test: fix cross-platform unit failures (#41075) --- packages/core/test/tool-shell.test.ts | 3 +- packages/tui/src/context/storage.tsx | 22 ++++++- .../tui/test/cli/tui/dialog-open.test.tsx | 22 ++++--- .../tui/test/context/session-tabs.test.tsx | 65 +++++++------------ 4 files changed, 58 insertions(+), 54 deletions(-) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index ccc628ad21..9d2f834de8 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -524,7 +524,8 @@ describe("ShellTool", () => { const content = settled.content?.[0] if (!content || content.type !== "text") throw new Error("Expected text content") expect(content.text).not.toContain("one") - expect(content.text).toStartWith("two\nthree") + // Windows shells emit CRLF; the assertion targets line limits, not line endings. + expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree") expect(content.text).toContain("output truncated; full output saved to:") }) }, diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index 441d32c2d6..47aa497eea 100644 --- a/packages/tui/src/context/storage.tsx +++ b/packages/tui/src/context/storage.tsx @@ -27,6 +27,7 @@ export interface Storage { * JSON-serializable. */ memory(key: string, options: { readonly initial: Value }): MemoryEntry + flush(): Promise } function clone(value: Value) { @@ -46,6 +47,7 @@ function segment(value: string) { function createStorage(root: string, channel: string) { const entries = new Map; readonly reload: () => void }>() const memories = new Map>() + const pending = new Set>() const directory = path.join(root, segment(channel), "tui") const locks = path.join(root, segment(channel), "locks") mkdirSync(directory, { recursive: true }) @@ -66,8 +68,8 @@ function createStorage(root: string, channel: string) { const [store, setStore] = createStore(load()) const merge = (next: Value) => reconcile(next, { key: options.key }) const reload = () => batch(() => setStore(merge(load()))) - const update = (mutation: (draft: Value) => void) => - Flock.withLock( + const update = (mutation: (draft: Value) => void) => { + const operation = Flock.withLock( file, async () => { const draft = load() @@ -78,6 +80,13 @@ function createStorage(root: string, channel: string) { }, { dir: locks }, ) + pending.add(operation) + operation.then( + () => pending.delete(operation), + () => pending.delete(operation), + ) + return operation + } const entry = [store, update] as const entries.set(file, { value: entry as Entry, reload }) return entry @@ -90,6 +99,15 @@ function createStorage(root: string, channel: string) { memories.set(key, entry as MemoryEntry) return entry }, + async flush() { + const failures: unknown[] = [] + while (pending.size > 0) { + const results = await Promise.allSettled(pending) + failures.push(...results.filter((result) => result.status === "rejected").map((result) => result.reason)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, "Storage writes failed") + }, } const watcher = watch(directory, () => entries.forEach((entry) => entry.reload())) diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index 8431b684b4..bcdce8342f 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -1,9 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import { mkdtempSync, rmSync } from "fs" -import { tmpdir } from "os" -import path from "path" import { onMount } from "solid-js" import { DialogOpen } from "../../../src/component/dialog-open" import { ConfigProvider } from "../../../src/config" @@ -14,12 +11,13 @@ import { LocationProvider, useLocation } from "../../../src/context/location" import { RouteProvider, useRoute } from "../../../src/context/route" import { TuiAppProvider } from "../../../src/context/runtime" import { SessionTabsProvider } from "../../../src/context/session-tabs" -import { StorageProvider } from "../../../src/context/storage" +import { StorageProvider, useStorage } from "../../../src/context/storage" import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" +import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" test("selecting an unhydrated session preserves its location", async () => { @@ -52,7 +50,7 @@ test("selecting an unhydrated session preserves its location", async () => { expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" }) expect(fixture.location.ref).toEqual(remote) } finally { - fixture.dispose() + await fixture.dispose() } }) @@ -94,7 +92,7 @@ test("shows the current project and opens its root", async () => { expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } }) expect(fixture.location.ref).toEqual({ directory: root }) } finally { - fixture.dispose() + await fixture.dispose() } }) @@ -149,7 +147,7 @@ test("preserves a moved project when sessions arrive", async () => { expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } }) } finally { - fixture.dispose() + await fixture.dispose() } }) @@ -160,18 +158,21 @@ async function renderOpen( location: ReturnType }) => void | Promise, ) { - const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-")) + const temporary = await tmpdir() + const state = temporary.path const events = createEventStream() const calls = createFetch(handler, events) let route!: ReturnType let location!: ReturnType let data!: ReturnType + let storage!: ReturnType function Probe() { const dialog = useDialog() route = useRoute() location = useLocation() data = useData() + storage = useStorage() onMount( () => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => )), ) @@ -223,9 +224,10 @@ async function renderOpen( get data() { return data }, - dispose() { + async dispose() { app.renderer.destroy() - rmSync(state, { recursive: true, force: true }) + await storage.flush() + await temporary[Symbol.asyncDispose]() }, } } diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 0739f04a5e..66ef608e7e 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -1,9 +1,8 @@ /** @jsxImportSource @opentui/solid */ -import { afterAll, expect, test } from "bun:test" +import { expect, test } from "bun:test" import type { OpenCodeEvent } from "@opencode-ai/client" import { testRender } from "@opentui/solid" -import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs" -import { tmpdir } from "os" +import { mkdirSync, watch } from "fs" import path from "path" import { ConfigProvider } from "../../src/config" import { ClientProvider, useClient } from "../../src/context/client" @@ -12,9 +11,10 @@ import { RouteProvider, useRoute } from "../../src/context/route" import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model" -import { StorageProvider } from "../../src/context/storage" +import { StorageProvider, useStorage } from "../../src/context/storage" import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client" import { TestTuiContexts } from "../fixture/tui-environment" +import { tmpdir } from "../fixture/fixture" import { createTuiResolvedConfig } from "../fixture/tui-runtime" async function wait(fn: () => boolean | Promise, timeout = 2_000) { @@ -25,35 +25,12 @@ async function wait(fn: () => boolean | Promise, timeout = 2_000) { } } -// State directories are removed after the whole suite instead of per test: persistence writes are -// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write. -const stateDirs: string[] = [] - -afterAll(async () => { - for (const dir of stateDirs) { - // Drain any lock still held by a late write before deleting the tree beneath it. - await wait(() => { - try { - return readdirSync(path.join(dir, "test", "locks")).length === 0 - } catch { - return true - } - }).catch(() => undefined) - rmSync(dir, { recursive: true, force: true }) - } -}) - -function stateDir(prefix: string) { - const dir = mkdtempSync(path.join(tmpdir(), prefix)) - stateDirs.push(dir) - return dir -} - async function renderSessionTabs( initialSessionID: string, options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise }, ) { - const state = options?.state ?? stateDir("opencode-session-tabs-") + const temporary = options?.state ? undefined : await tmpdir() + const state = options?.state ?? temporary!.path if (options?.persisted) { const file = path.join(state, "test", "tui", "tabs.json") mkdirSync(path.dirname(file), { recursive: true }) @@ -88,12 +65,14 @@ async function renderSessionTabs( let route!: ReturnType let client!: ReturnType let data!: ReturnType + let storage!: ReturnType function Probe() { tabs = useSessionTabs() route = useRoute() client = useClient() data = useData() + storage = useStorage() return } @@ -127,8 +106,10 @@ async function renderSessionTabs( sessions, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), - destroy() { + async destroy() { app.renderer.destroy() + await storage.flush() + await temporary?.[Symbol.asyncDispose]() }, } } @@ -149,7 +130,7 @@ test("loads persisted tab metadata concurrently on connect", async () => { await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined) } finally { release() - setup.destroy() + await setup.destroy() } }) @@ -159,17 +140,19 @@ test("stores session tabs for the current working directory by default", async ( try { const file = path.join(setup.state, "test", "tui", "tabs.json") await wait(() => Bun.file(file).size > 0) - expect(await Bun.file(file).json()).toEqual({ - global: { tabs: [], unread: {} }, - cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } }, - }) + const stored = await Bun.file(file).json() + expect(stored.global).toEqual({ tabs: [], unread: {} }) + expect(Object.keys(stored.cwd)).toEqual([directory]) + expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"]) + expect(stored.cwd[directory].unread).toEqual({}) } finally { - setup.destroy() + await setup.destroy() } }) test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => { - const state = stateDir("opencode-session-tabs-shared-") + await using temporary = await tmpdir() + const state = temporary.path let titled: Awaited> | undefined let untitled: Awaited> | undefined @@ -206,8 +189,8 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session expect(observed).toEqual(["Generated title"]) } finally { - titled?.destroy() - untitled?.destroy() + if (titled) await titled.destroy() + if (untitled) await untitled.destroy() } }) @@ -255,7 +238,7 @@ test("user prompt admissions pulse an already-busy background tab", async () => expect(setup.tabs.status("active").promptPulse).toBe(0) expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true }) } finally { - setup.destroy() + await setup.destroy() } }) @@ -286,6 +269,6 @@ test("tracks a temporary new session tab across close and creation", async () => expect(setup.tabs.newTab()).toBe(false) expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE) } finally { - setup.destroy() + await setup.destroy() } })