test: fix cross-platform unit failures (#41075)
This commit is contained in:
@@ -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:")
|
||||
})
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Storage {
|
||||
* JSON-serializable.
|
||||
*/
|
||||
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
|
||||
flush(): Promise<void>
|
||||
}
|
||||
|
||||
function clone<Value extends object>(value: Value) {
|
||||
@@ -46,6 +47,7 @@ function segment(value: string) {
|
||||
function createStorage(root: string, channel: string) {
|
||||
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
||||
const memories = new Map<string, MemoryEntry<object>>()
|
||||
const pending = new Set<Promise<void>>()
|
||||
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<object>, reload })
|
||||
return entry
|
||||
@@ -90,6 +99,15 @@ function createStorage(root: string, channel: string) {
|
||||
memories.set(key, entry as MemoryEntry<object>)
|
||||
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()))
|
||||
|
||||
@@ -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<typeof useLocation>
|
||||
}) => void | Promise<void>,
|
||||
) {
|
||||
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<typeof useRoute>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
|
||||
function Probe() {
|
||||
const dialog = useDialog()
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
storage = useStorage()
|
||||
onMount(
|
||||
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
|
||||
)
|
||||
@@ -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]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean>, timeout = 2_000) {
|
||||
@@ -25,35 +25,12 @@ async function wait(fn: () => boolean | Promise<boolean>, 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<void> },
|
||||
) {
|
||||
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<typeof useRoute>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
|
||||
function Probe() {
|
||||
tabs = useSessionTabs()
|
||||
route = useRoute()
|
||||
client = useClient()
|
||||
data = useData()
|
||||
storage = useStorage()
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | 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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user