Просмотр исходного кода

test: fix cross-platform unit failures (#41075)

Kit Langton 1 неделя назад
Родитель
Сommit
e6d20440f9

+ 2 - 1
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:")
         })
       },

+ 20 - 2
packages/tui/src/context/storage.tsx

@@ -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()))

+ 12 - 10
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<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]()
     },
   }
 }

+ 24 - 41
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<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()
   }
 })