Browse Source

Add some tests

James Long 1 week ago
parent
commit
2b0fdcc449

+ 293 - 0
packages/opencode/test/cli/tui/sync-provider.test.tsx

@@ -0,0 +1,293 @@
+/** @jsxImportSource @opentui/solid */
+import { afterEach, describe, expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import { onMount } from "solid-js"
+import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
+import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
+import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
+import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
+import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
+
+const sighup = new Set(process.listeners("SIGHUP"))
+
+afterEach(() => {
+  for (const fn of process.listeners("SIGHUP")) {
+    if (!sighup.has(fn)) process.off("SIGHUP", fn)
+  }
+})
+
+function json(data: unknown) {
+  return new Response(JSON.stringify(data), {
+    headers: {
+      "content-type": "application/json",
+    },
+  })
+}
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+function data(workspace?: string | null) {
+  const tag = workspace ?? "root"
+  return {
+    session: {
+      id: "ses_1",
+      title: `session-${tag}`,
+      workspaceID: workspace ?? undefined,
+      time: {
+        updated: 1,
+      },
+    },
+    message: {
+      info: {
+        id: "msg_1",
+        sessionID: "ses_1",
+        role: "assistant",
+        time: {
+          created: 1,
+          completed: 1,
+        },
+      },
+      parts: [
+        {
+          id: "part_1",
+          messageID: "msg_1",
+          sessionID: "ses_1",
+          type: "text",
+          text: `part-${tag}`,
+        },
+      ],
+    },
+    todo: [
+      {
+        id: `todo-${tag}`,
+        content: `todo-${tag}`,
+        status: "pending",
+        priority: "medium",
+      },
+    ],
+    diff: [
+      {
+        file: `${tag}.ts`,
+        patch: "",
+        additions: 0,
+        deletions: 0,
+      },
+    ],
+  }
+}
+
+type Hit = {
+  path: string
+  workspace?: string
+}
+
+function createFetch(log: Hit[]) {
+  return Object.assign(
+    async (input: RequestInfo | URL, init?: RequestInit) => {
+      const req = new Request(input, init)
+      const url = new URL(req.url)
+      const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
+      log.push({
+        path: url.pathname,
+        workspace,
+      })
+
+      if (url.pathname === "/config/providers") {
+        return json({ providers: [], default: {} })
+      }
+      if (url.pathname === "/provider") {
+        return json({ all: [], default: {}, connected: [] })
+      }
+      if (url.pathname === "/experimental/console") {
+        return json({})
+      }
+      if (url.pathname === "/agent") {
+        return json([])
+      }
+      if (url.pathname === "/config") {
+        return json({})
+      }
+      if (url.pathname === "/project/current") {
+        return json({ id: `proj-${workspace ?? "root"}` })
+      }
+      if (url.pathname === "/path") {
+        return json({
+          state: `/tmp/${workspace ?? "root"}/state`,
+          config: `/tmp/${workspace ?? "root"}/config`,
+          worktree: "/tmp/worktree",
+          directory: `/tmp/${workspace ?? "root"}`,
+        })
+      }
+      if (url.pathname === "/session") {
+        return json([])
+      }
+      if (url.pathname === "/command") {
+        return json([])
+      }
+      if (url.pathname === "/lsp") {
+        return json([])
+      }
+      if (url.pathname === "/mcp") {
+        return json({})
+      }
+      if (url.pathname === "/experimental/resource") {
+        return json({})
+      }
+      if (url.pathname === "/formatter") {
+        return json([])
+      }
+      if (url.pathname === "/session/status") {
+        return json({})
+      }
+      if (url.pathname === "/provider/auth") {
+        return json({})
+      }
+      if (url.pathname === "/vcs") {
+        return json({ branch: "main" })
+      }
+      if (url.pathname === "/experimental/workspace") {
+        return json([{ id: "ws_a" }, { id: "ws_b" }])
+      }
+      if (url.pathname === "/session/ses_1") {
+        return json(data(workspace).session)
+      }
+      if (url.pathname === "/session/ses_1/message") {
+        return json([data(workspace).message])
+      }
+      if (url.pathname === "/session/ses_1/todo") {
+        return json(data(workspace).todo)
+      }
+      if (url.pathname === "/session/ses_1/diff") {
+        return json(data(workspace).diff)
+      }
+
+      throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
+    },
+    { preconnect: fetch.preconnect.bind(fetch) },
+  ) satisfies typeof fetch
+}
+
+async function mount(log: Hit[]) {
+  let project!: ReturnType<typeof useProject>
+  let sync!: ReturnType<typeof useSync>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const app = await testRender(() => (
+    <SDKProvider
+      url="http://test"
+      directory="/tmp/root"
+      fetch={createFetch(log)}
+      events={{ subscribe: async () => () => {} }}
+    >
+      <ArgsProvider continue={false}>
+        <ExitProvider>
+          <ProjectProvider>
+            <SyncProvider>
+              <Probe
+                onReady={(ctx) => {
+                  project = ctx.project
+                  sync = ctx.sync
+                  done()
+                }}
+              />
+            </SyncProvider>
+          </ProjectProvider>
+        </ExitProvider>
+      </ArgsProvider>
+    </SDKProvider>
+  ))
+
+  await ready
+  return { app, project, sync }
+}
+
+async function waitBoot(log: Hit[], workspace?: string) {
+  await wait(() => log.some((item) => item.path === "/experimental/workspace"))
+  if (!workspace) return
+  await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
+}
+
+function Probe(props: {
+  onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
+}) {
+  const project = useProject()
+  const sync = useSync()
+
+  onMount(() => {
+    props.onReady({ project, sync })
+  })
+
+  return <box />
+}
+
+describe("SyncProvider", () => {
+  test("re-runs bootstrap requests when the active workspace changes", async () => {
+    const log: Hit[] = []
+    const { app, project } = await mount(log)
+
+    try {
+      await waitBoot(log)
+      log.length = 0
+
+      project.workspace.set("ws_a")
+
+      await waitBoot(log, "ws_a")
+
+      expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
+      expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
+      expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
+      expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("clears full-sync cache when the active workspace changes", async () => {
+    const log: Hit[] = []
+    const { app, project, sync } = await mount(log)
+
+    try {
+      await waitBoot(log)
+
+      log.length = 0
+      project.workspace.set("ws_a")
+      await waitBoot(log, "ws_a")
+      expect(project.workspace.current()).toBe("ws_a")
+
+      log.length = 0
+      await sync.session.sync("ses_1")
+
+      expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
+      expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
+      expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
+      expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
+      expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
+
+      log.length = 0
+      project.workspace.set("ws_b")
+      await waitBoot(log, "ws_b")
+      expect(project.workspace.current()).toBe("ws_b")
+
+      log.length = 0
+      await sync.session.sync("ses_1")
+      await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
+
+      expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
+      expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
+      expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
+      expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
+      expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+})

+ 175 - 0
packages/opencode/test/cli/tui/use-event.test.tsx

@@ -0,0 +1,175 @@
+/** @jsxImportSource @opentui/solid */
+import { describe, expect, test } from "bun:test"
+import { testRender } from "@opentui/solid"
+import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
+import { onMount } from "solid-js"
+import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
+import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
+import { useEvent } from "../../../src/cli/cmd/tui/context/event"
+
+async function wait(fn: () => boolean, timeout = 2000) {
+  const start = Date.now()
+  while (!fn()) {
+    if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
+    await Bun.sleep(10)
+  }
+}
+
+function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
+  return {
+    directory: input.directory,
+    workspace: input.workspace,
+    payload,
+  }
+}
+
+function vcs(branch: string): Event {
+  return {
+    type: "vcs.branch.updated",
+    properties: {
+      branch,
+    },
+  }
+}
+
+function update(version: string): Event {
+  return {
+    type: "installation.update-available",
+    properties: {
+      version,
+    },
+  }
+}
+
+function createSource() {
+  let fn: ((event: GlobalEvent) => void) | undefined
+
+  return {
+    source: {
+      subscribe: async (handler: (event: GlobalEvent) => void) => {
+        fn = handler
+        return () => {
+          if (fn === handler) fn = undefined
+        }
+      },
+    },
+    emit(evt: GlobalEvent) {
+      if (!fn) throw new Error("event source not ready")
+      fn(evt)
+    },
+  }
+}
+
+async function mount() {
+  const source = createSource()
+  const seen: Event[] = []
+  let project!: ReturnType<typeof useProject>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  const app = await testRender(() => (
+    <SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
+      <ProjectProvider>
+        <Probe
+          onReady={(ctx) => {
+            project = ctx.project
+            done()
+          }}
+          seen={seen}
+        />
+      </ProjectProvider>
+    </SDKProvider>
+  ))
+
+  await ready
+  return { app, emit: source.emit, project, seen }
+}
+
+function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
+  const project = useProject()
+  const event = useEvent()
+
+  onMount(() => {
+    event.subscribe((evt) => {
+      props.seen.push(evt)
+    })
+    props.onReady({ project })
+  })
+
+  return <box />
+}
+
+describe("useEvent", () => {
+  test("delivers matching directory events without an active workspace", async () => {
+    const { app, emit, seen } = await mount()
+
+    try {
+      emit(event(vcs("main"), { directory: "/tmp/root" }))
+
+      await wait(() => seen.length === 1)
+
+      expect(seen).toEqual([vcs("main")])
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("ignores non-matching directory events without an active workspace", async () => {
+    const { app, emit, seen } = await mount()
+
+    try {
+      emit(event(vcs("other"), { directory: "/tmp/other" }))
+      await Bun.sleep(30)
+
+      expect(seen).toHaveLength(0)
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("delivers matching workspace events when a workspace is active", async () => {
+    const { app, emit, project, seen } = await mount()
+
+    try {
+      project.workspace.set("ws_a")
+      emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
+
+      await wait(() => seen.length === 1)
+
+      expect(seen).toEqual([vcs("ws")])
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("ignores non-matching workspace events when a workspace is active", async () => {
+    const { app, emit, project, seen } = await mount()
+
+    try {
+      project.workspace.set("ws_a")
+      emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
+      await Bun.sleep(30)
+
+      expect(seen).toHaveLength(0)
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+
+  test("delivers truly global events even when a workspace is active", async () => {
+    const { app, emit, project, seen } = await mount()
+
+    try {
+      project.workspace.set("ws_a")
+      emit(event(update("1.2.3"), { directory: "global" }))
+
+      await wait(() => seen.length === 1)
+
+      expect(seen).toEqual([update("1.2.3")])
+    } finally {
+      app.renderer.destroy()
+    }
+  })
+})