From ffd38aade4d670dfdec6c6fdc8d74ea18690e2ff Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 9 Jun 2026 10:23:09 -0400 Subject: [PATCH] refactor(tui): centralize application exit --- .github/workflows/test.yml | 24 +-- packages/app/package.json | 3 +- packages/app/playwright.config.ts | 8 +- packages/core/package.json | 3 +- packages/effect-drizzle-sqlite/package.json | 3 +- packages/http-recorder/package.json | 3 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 3 +- packages/opencode/src/cli/cmd/tui.ts | 2 +- packages/tui/package.json | 4 +- packages/tui/src/app.tsx | 203 ++++++++++-------- .../tui/src/component/error-component.tsx | 10 +- packages/tui/src/component/prompt/index.tsx | 5 +- .../tui/src/context/aggregate-failures.ts | 51 ----- packages/tui/src/context/exit.tsx | 8 + packages/tui/src/context/sync.tsx | 33 +-- .../test/context/aggregate-failures.test.ts | 95 -------- packages/ui/package.json | 3 +- turbo.json | 19 -- 19 files changed, 150 insertions(+), 332 deletions(-) delete mode 100644 packages/tui/src/context/aggregate-failures.ts create mode 100644 packages/tui/src/context/exit.tsx delete mode 100644 packages/tui/test/context/aggregate-failures.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7498a84ae91..4c36f41106c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test:ci --log-order=stream --log-prefix=task + run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} @@ -74,26 +74,6 @@ jobs: working-directory: packages/opencode run: bun run test:httpapi - - name: Publish unit reports - if: always() - uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 - with: - report_paths: packages/*/.artifacts/unit/junit.xml - check_name: "unit results (${{ matrix.settings.name }})" - detailed_summary: true - include_time_in_summary: true - fail_on_failure: false - - - name: Upload unit artifacts - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} - include-hidden-files: true - if-no-files-found: ignore - retention-days: 7 - path: packages/*/.artifacts/unit/junit.xml - e2e: name: e2e (${{ matrix.settings.name }}) strategy: @@ -151,7 +131,6 @@ jobs: run: bun --cwd packages/app test:e2e:local env: CI: true - PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml timeout-minutes: 30 - name: Upload Playwright artifacts @@ -162,6 +141,5 @@ jobs: if-no-files-found: ignore retention-days: 7 path: | - packages/app/e2e/junit-*.xml packages/app/e2e/test-results packages/app/e2e/playwright-report diff --git a/packages/app/package.json b/packages/app/package.json index 0336d7cd49f..6bce26e3d1d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,8 +18,7 @@ "build": "vite build", "serve": "vite preview", "test": "bun run test:unit", - "test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:unit": "bun test --preload ./happydom.ts ./src", + "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index e9fb1cfe4ed..d9648a88ba6 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined -const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const - -if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) { - reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }]) -} - export default defineConfig({ testDir: "./e2e", outputDir: "./e2e/test-results", @@ -24,7 +18,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers, - reporter, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], webServer: { command, url: baseURL, diff --git a/packages/core/package.json b/packages/core/package.json index e720d9357c0..482d66bb648 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,8 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --only-failures", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index ece1833f7bf..84323bad200 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -6,8 +6,7 @@ "license": "MIT", "private": true, "scripts": { - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index bb36d3df195..a97cd31e56f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -27,8 +27,7 @@ "access": "public" }, "scripts": { - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" diff --git a/packages/llm/package.json b/packages/llm/package.json index eea583f71a2..d6d981925ae 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "setup:recording-env": "bun run script/setup-recording-env.ts", - "test": "bun test --timeout 30000", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 95ef65fe146..e9b422cdd84 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -7,8 +7,7 @@ "private": true, "scripts": { "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 7cda89dbabb..68941e976ac 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -213,12 +213,12 @@ export const TuiThreadCommand = cmd({ } finally { await stop() } - process.exit(0) } finally { try { unguard?.() } catch {} } + process.exit(0) }, }) // scratch diff --git a/packages/tui/package.json b/packages/tui/package.json index 46711311e60..accd82daf3f 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "MIT", "scripts": { - "test": "bun test --timeout 30000", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "exports": { @@ -15,6 +15,7 @@ "./config": "./src/config/index.tsx", "./context/args": "./src/context/args.tsx", "./context/epilogue": "./src/context/epilogue.tsx", + "./context/exit": "./src/context/exit.tsx", "./context/kv": "./src/context/kv.tsx", "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", @@ -26,7 +27,6 @@ "./attention": "./src/attention.ts", "./editor": "./src/editor.ts", "./editor-zed": "./src/editor-zed.ts", - "./context/aggregate-failures": "./src/context/aggregate-failures.ts", "./runtime": "./src/runtime.tsx", "./terminal-win32": "./src/terminal-win32.ts", "./config/keybind": "./src/config/keybind.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ec1fe4d7d3a..edf9085ae81 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -5,6 +5,7 @@ import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core" @@ -80,6 +81,7 @@ import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" +import { cliErrorMessage, errorFormat } from "./util/error" const appGlobalBindingCommands = [ "session.list", @@ -175,8 +177,8 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service - const epilogue = { value: undefined as string | undefined } - const output = yield* Effect.scoped( + const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } + yield* Effect.scoped( Effect.gen(function* () { const renderer = yield* Effect.acquireRelease( Effect.tryPromise(() => @@ -194,7 +196,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, }), ), - (renderer) => Effect.sync(() => destroyRenderer(renderer)), + (renderer) => + Effect.sync(() => { + destroyRenderer(renderer) + }), ) win32DisableProcessedInput() const keymap = createDefaultOpenTuiKeymap(renderer) @@ -212,7 +217,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }), ) yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) - const shutdown = yield* Deferred.make() + const shutdown = yield* Deferred.make() const onSighup = () => destroyRenderer(renderer) yield* Effect.acquireRelease( Effect.sync(() => process.on("SIGHUP", onSighup)), @@ -229,103 +234,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { await render(() => { return ( - }> - - - { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + }} + > + (exit.epilogue = value)}> + }> + - - (epilogue.value = value)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) }, renderer) }) yield* Deferred.await(shutdown) - return epilogue.value }), ) yield* Effect.sync(() => { win32FlushInputBuffer() - if (output) process.stdout.write(output + "\n") + if (exit.reason !== undefined) + process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n") + if (exit.epilogue) process.stdout.write(exit.epilogue + "\n") }) }) @@ -346,6 +364,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() const project = useProject() + const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) @@ -786,7 +805,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi title: "Exit the app", slashName: "exit", slashAliases: ["quit", "q"], - run: () => destroyRenderer(renderer), + run: () => exit(), category: "System", }, { @@ -1020,7 +1039,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, ) - destroyRenderer(renderer) + void exit() }) const plugin = createMemo(() => { diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index d42f5d18f83..6862e644388 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -1,19 +1,19 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { createSignal } from "solid-js" import { getScrollAcceleration } from "../util/scroll" import { useClipboard } from "../context/clipboard" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { destroyRenderer } from "../util/renderer" +import { useExit } from "../context/exit" export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) { const term = useTerminalDimensions() - const renderer = useRenderer() + const exit = useExit() const clipboard = useClipboard() useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { - destroyRenderer(renderer) + void exit() } }) const [copied, setCopied] = createSignal(false) @@ -66,7 +66,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?: Reset TUI - destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}> + void exit()} backgroundColor={colors.primary} padding={1}> Exit diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0a9f103ef57..0b831196326 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -27,7 +27,7 @@ import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" -import { destroyRenderer } from "../../util/renderer" +import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" @@ -163,6 +163,7 @@ export function Prompt(props: PromptProps) { const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() + const exit = useExit() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() @@ -955,7 +956,7 @@ export function Prompt(props: PromptProps) { if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { - destroyRenderer(renderer) + void exit() return true } const selectedModel = local.model.current() diff --git a/packages/tui/src/context/aggregate-failures.ts b/packages/tui/src/context/aggregate-failures.ts deleted file mode 100644 index ab54f2510dc..00000000000 --- a/packages/tui/src/context/aggregate-failures.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { cliErrorMessage } from "../util/error" - -/** - * Aggregate Promise.allSettled results into a single Error that names every - * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap - * boundaries so a single 4xx doesn't drown its parallel siblings as - * unhandled rejections — every failure surfaces in one labeled message. - */ -export type LabeledSettled = { - name: string - result: PromiseSettledResult -} - -export function aggregateFailures(labeled: LabeledSettled[]): Error | null { - const failed = labeled.filter( - (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", - ) - if (failed.length === 0) return null - - const reasons = Array.from( - failed - .map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) })) - .reduce((grouped, failure) => { - grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name]) - return grouped - }, new Map()) - .entries(), - ) - .map(([message, names]) => - names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`, - ) - .join("; ") - const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` - const err = new Error(summary) - err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } - return err -} - -function reasonMessage(reason: unknown): string { - const formatted = cliErrorMessage(reason) - if (formatted) return formatted - - if (reason instanceof Error) return reason.message - if (typeof reason === "string") return reason - if (reason && typeof reason === "object") { - const obj = reason as { message?: unknown; name?: unknown } - if (typeof obj.message === "string") return obj.message - if (typeof obj.name === "string") return obj.name - } - return String(reason) -} diff --git a/packages/tui/src/context/exit.tsx b/packages/tui/src/context/exit.tsx new file mode 100644 index 00000000000..e01f29ed15e --- /dev/null +++ b/packages/tui/src/context/exit.tsx @@ -0,0 +1,8 @@ +import { createSimpleContext } from "./helper" + +export type Exit = (reason?: unknown) => void + +export const { use: useExit, provider: ExitProvider } = createSimpleContext({ + name: "Exit", + init: (input: { exit: Exit }) => input.exit, +}) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 54e08219ea2..4882c13920f 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -26,13 +26,11 @@ import { useEvent } from "./event" import { useSDK } from "./sdk" import { useTuiStartup } from "./runtime" import { createSimpleContext } from "./helper" -import { useRenderer } from "@opentui/solid" +import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import path from "path" -import { aggregateFailures } from "./aggregate-failures" import { useKV } from "./kv" -import { destroyRenderer } from "../util/renderer" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], @@ -424,7 +422,7 @@ export const { } }) - const renderer = useRenderer() + const exit = useExit() const args = useArgs() async function bootstrap(input: { fatal?: boolean } = {}) { @@ -442,23 +440,14 @@ export const { .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: { name: string; promise: Promise }[] = [ - { name: "config.providers", promise: providersPromise }, - { name: "provider.list", promise: providerListPromise }, - { name: "app.agents", promise: agentsPromise }, - { name: "config.get", promise: configPromise }, - { name: "project.sync", promise: projectPromise }, - ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), - ] - - await Promise.allSettled(blockingRequests.map((r) => r.promise)) - .then((settled) => { - // Surface every failed endpoint in one labeled message instead of - // letting the first rejection drown its siblings as unhandled - // rejections. - const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) - if (failure) throw failure - }) + await Promise.all([ + providersPromise, + providerListPromise, + agentsPromise, + configPromise, + projectPromise, + ...(args.continue ? [sessionListPromise] : []), + ]) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) @@ -523,7 +512,7 @@ export const { stack: e instanceof Error ? e.stack : undefined, }) if (fatal) { - destroyRenderer(renderer) + exit(e) } else { throw e } diff --git a/packages/tui/test/context/aggregate-failures.test.ts b/packages/tui/test/context/aggregate-failures.test.ts deleted file mode 100644 index 2a40887a808..00000000000 --- a/packages/tui/test/context/aggregate-failures.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Regression test for the TUI bootstrap aggregation helper. Replaces the - * pre-fix Promise.all behavior where the first rejection drowned every - * sibling endpoint's failure as an unhandled rejection. - */ -import { describe, expect, test } from "bun:test" -import { aggregateFailures } from "@opencode-ai/tui/context/aggregate-failures" - -describe("aggregateFailures", () => { - test("returns null when every result is fulfilled", () => { - expect( - aggregateFailures([ - { name: "config", result: { status: "fulfilled", value: 1 } }, - { name: "providers", result: { status: "fulfilled", value: 2 } }, - ]), - ).toBeNull() - }) - - test("names the failed endpoint when one rejects", () => { - const err = aggregateFailures([ - { name: "config", result: { status: "fulfilled", value: 1 } }, - { - name: "providers", - result: { status: "rejected", reason: new Error("Service unavailable") }, - }, - ]) - expect(err).toBeInstanceOf(Error) - expect(err!.message).toContain("1 of 2") - expect(err!.message).toContain("providers: Service unavailable") - }) - - test("names every failed endpoint when multiple reject", () => { - const err = aggregateFailures([ - { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, - { name: "providers", result: { status: "fulfilled", value: 1 } }, - { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, - ]) - expect(err).toBeInstanceOf(Error) - expect(err!.message).toContain("2 of 3") - expect(err!.message).toContain("config: 400 Bad Request") - expect(err!.message).toContain("agents: boom") - }) - - test("formats structured config errors hidden inside SDK error causes", () => { - const configError = { - name: "ConfigInvalidError", - data: { - path: "/tmp/opencode.json", - issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }], - }, - } - const err = aggregateFailures([ - { - name: "config.get", - result: { - status: "rejected", - reason: new Error("ConfigInvalidError", { - cause: { - body: configError, - }, - }), - }, - }, - ]) - - expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json") - expect(err!.message).toContain("Expected object provider.anthropic.options") - }) - - test("deduplicates identical failure messages across startup requests", () => { - const reason = new Error("same config problem") - const err = aggregateFailures([ - { name: "config.providers", result: { status: "rejected", reason } }, - { name: "provider.list", result: { status: "rejected", reason } }, - { name: "app.agents", result: { status: "rejected", reason } }, - { name: "config.get", result: { status: "rejected", reason } }, - { name: "project.sync", result: { status: "fulfilled", value: undefined } }, - ]) - - expect(err!.message).toContain("4 of 5 requests failed: same config problem") - expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get") - expect(err!.message.match(/same config problem/g)?.length).toBe(1) - }) - - test("attaches structured failure list under .cause", () => { - const reason = new Error("nope") - const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) - expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] }) - }) - - test("falls back to String() for opaque reasons", () => { - const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) - expect(err!.message).toContain("x: 42") - }) -}) diff --git a/packages/ui/package.json b/packages/ui/package.json index ce934925aba..7665da87c66 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,8 +29,7 @@ }, "scripts": { "typecheck": "tsgo --noEmit", - "test": "bun test src", - "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test src --only-failures", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts", "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts" diff --git a/turbo.json b/turbo.json index 6c65881b857..220cd96e584 100644 --- a/turbo.json +++ b/turbo.json @@ -13,32 +13,13 @@ "outputs": [], "passThroughEnv": ["*"] }, - "test:ci": { - "outputs": [".artifacts/unit/junit.xml"], - "passThroughEnv": ["*"] - }, - "opencode#test:ci": { - "dependsOn": ["^build"], - "outputs": [".artifacts/unit/junit.xml"], - "passThroughEnv": ["*"] - }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": [] }, - "@opencode-ai/app#test:ci": { - "dependsOn": ["^build"], - "outputs": [".artifacts/unit/junit.xml"], - "passThroughEnv": ["*"] - }, "@opencode-ai/ui#test": { "dependsOn": ["^build"], "outputs": [] - }, - "@opencode-ai/ui#test:ci": { - "dependsOn": ["^build"], - "outputs": [".artifacts/unit/junit.xml"], - "passThroughEnv": ["*"] } } }