From 57f4d9430d8b2110da7279681b0e8b45f6cf9d14 Mon Sep 17 00:00:00 2001 From: qa Date: Thu, 8 Oct 2026 22:56:31 -0700 Subject: [PATCH 01/10] Build each test database from one migrated template Most server tests ran all 143 migrations per test (607 migrate calls, ~58 s of CPU per server run). `@bb/db/testing` now exposes `createMigratedConnection()`, which copies a template built once per run in the server and db `globalSetup` and shared with workers through a file, so isolated files deserialize it as well (0.4 ms instead of ~80 ms). The server's `createTestDb` and 52 server/scripts call sites move onto it, plus the db query-plan tests. A server test fails on any new `createConnection(":memory:")` outside three files that need a fresh migrate. AGENTS.md and the test-audit skill point to the helper. Co-Authored-By: Claude Opus 5.5 --- .bb/skills/test-audit/SKILL.md | 4 +- AGENTS.md | 2 +- .../hosts/host-environment-sync.test.ts | 13 +---- .../services/hosts/host-environment.test.ts | 4 +- .../src/services/machines/enrollments.test.ts | 6 +- .../machines/environment-storage.test.ts | 12 ++-- .../src/services/projects/attachments.test.ts | 13 ++--- .../server/test/app/host-shared-ports.test.ts | 6 +- apps/server/test/app/watch-interests.test.ts | 7 +-- apps/server/test/helpers/test-app.ts | 13 ++--- .../test/helpers/timeline-cache-fixture.ts | 5 +- .../test/in-memory-test-databases.test.ts | 36 ++++++++++++ apps/server/test/machine-auth.test.ts | 4 +- .../test/provider-corpus/corpus-harness.ts | 6 +- .../test/provider-corpus/synthetic-thread.ts | 6 +- .../database-maintenance-sweep.test.ts | 7 +-- .../test/services/entity-lookup.test.ts | 6 +- .../environments/environment-response.test.ts | 6 +- .../plugin-catalog-routes.test.ts | 6 +- .../plugin-catalog-service.test.ts | 6 +- .../third-party-marketplaces.test.ts | 6 +- .../services/plugins/builtin-plugins.test.ts | 6 +- .../plugins/git-source-intent.test.ts | 11 +--- .../services/plugins/official-plugins.test.ts | 6 +- .../plugin-agent-contributions.test.ts | 9 ++- .../plugins/plugin-agent-tools.test.ts | 9 ++- .../plugins/plugin-background.test.ts | 9 +-- .../plugins/plugin-cache-prune.test.ts | 9 +-- .../plugins/plugin-dev-build-problems.test.ts | 5 +- .../plugins/plugin-install-hook.test.ts | 6 +- .../services/plugins/plugin-install.test.ts | 6 +- .../plugins/plugin-mention-providers.test.ts | 9 ++- .../plugins/plugin-replacement.test.ts | 6 +- .../services/plugins/plugin-safe-mode.test.ts | 9 ++- .../test/services/plugins/plugin-sdk.test.ts | 6 +- .../services/plugins/plugin-service.test.ts | 12 ++-- .../plugins/plugin-settings-storage.test.ts | 6 +- .../plugins/plugin-state-snapshot.test.ts | 6 +- .../services/plugins/plugin-update.test.ts | 15 ++--- .../plugins/prebuilt-server-loading.test.ts | 14 ++--- .../test/services/prompt-history.test.ts | 11 ++-- apps/server/test/services/telemetry.test.ts | 11 +--- .../conversation-outline-performance.test.ts | 6 +- .../threads/lifecycle-outcome.test.ts | 6 +- .../threads/stored-event-decode-cache.test.ts | 6 +- .../threads/thread-runtime-display.test.ts | 6 +- .../threads/timeline-context-clear.test.ts | 6 +- .../threads/timeline-event-budget.test.ts | 5 +- .../threads/timeline-head-state.test.ts | 6 +- .../threads/timeline-in-turn-window.test.ts | 6 +- .../timeline-parented-pagination.test.ts | 6 +- .../threads/timeline-provider-input.test.ts | 6 +- .../timeline-workflow-progress-window.test.ts | 6 +- .../server/test/setup/migrated-db-template.ts | 3 + .../system/event-loop-stall-monitor.test.ts | 6 +- .../system/performance-diagnostics.test.ts | 6 +- .../threads/thread-create-helpers.test.ts | 9 +-- apps/server/test/threads/thread-data.test.ts | 6 +- .../server/test/threads/thread-parent.test.ts | 13 +---- .../threads/thread-provisioning-state.test.ts | 6 +- apps/server/vitest.config.ts | 1 + packages/db/package.json | 5 ++ packages/db/src/testing.ts | 55 +++++++++++++++++++ packages/db/test/data/app-settings.test.ts | 2 +- .../data/archived-thread-teardown.test.ts | 2 +- .../completed-event-output-migration.test.ts | 2 +- .../test/data/environment-lifecycle.test.ts | 2 +- packages/db/test/data/environments.test.ts | 2 +- .../data/event-rewrite-generation.test.ts | 2 +- packages/db/test/data/events.test.ts | 2 +- packages/db/test/data/hosts.test.ts | 2 +- packages/db/test/data/machines.test.ts | 2 +- packages/db/test/data/maintenance.test.ts | 2 +- .../db/test/data/pending-interactions.test.ts | 2 +- .../db/test/data/plugin-persistence.test.ts | 2 +- .../data/project-execution-defaults.test.ts | 2 +- packages/db/test/data/project-sources.test.ts | 2 +- packages/db/test/data/projects.test.ts | 2 +- .../db/test/data/queued-message-waits.test.ts | 2 +- .../test/data/queued-thread-messages.test.ts | 2 +- .../test/data/retained-event-outputs.test.ts | 2 +- packages/db/test/data/server-move.test.ts | 2 +- packages/db/test/data/sessions.test.ts | 2 +- packages/db/test/data/sweeps.test.ts | 2 +- .../db/test/data/terminal-sessions.test.ts | 2 +- packages/db/test/data/thread-count.test.ts | 2 +- .../data/thread-lifecycle-ownership.test.ts | 2 +- .../db/test/data/thread-lifecycle.test.ts | 2 +- .../test/data/thread-plugin-metadata.test.ts | 2 +- packages/db/test/data/thread-pruning.test.ts | 2 +- packages/db/test/data/thread-running.test.ts | 2 +- .../test/data/thread-search-ranking.test.ts | 2 +- packages/db/test/data/thread-search.test.ts | 2 +- packages/db/test/data/threads.test.ts | 2 +- packages/db/test/data/ui-preferences.test.ts | 2 +- .../db/test/helpers/migrated-connection.ts | 29 ---------- packages/db/test/migrate.test.ts | 2 +- packages/db/test/query-plans.test.ts | 6 +- .../db/test/setup/migrated-db-template.ts | 3 + packages/db/vitest.config.ts | 1 + .../scripts/test/seed-perf-fixture.test.ts | 8 +-- 101 files changed, 295 insertions(+), 348 deletions(-) create mode 100644 apps/server/test/in-memory-test-databases.test.ts create mode 100644 apps/server/test/setup/migrated-db-template.ts create mode 100644 packages/db/src/testing.ts delete mode 100644 packages/db/test/helpers/migrated-connection.ts create mode 100644 packages/db/test/setup/migrated-db-template.ts diff --git a/.bb/skills/test-audit/SKILL.md b/.bb/skills/test-audit/SKILL.md index 6981495abce..7f16284e4f6 100644 --- a/.bb/skills/test-audit/SKILL.md +++ b/.bb/skills/test-audit/SKILL.md @@ -39,8 +39,8 @@ pass after the owner-boundary repair. A regression test that never demonstrably failed proves the mock, not the fix. One regression at the owner boundary covers the bug; do not replay the same scenario at every layer it crosses. -bb test rules: never mock the database; use `createConnection(":memory:")` -and `migrate(db)` from `@bb/db`. Vitest projects come from +bb test rules: never mock the database; use `createMigratedConnection()` from +`@bb/db/testing`, which copies a template built by the real migrations. Vitest projects come from `sharedWorkerProjects` in `vitest.shared.ts`: Node test files share workers (`isolate: false`), and files that use `vi.mock`, `vi.stubGlobal`, fake timers, `process.env` writes, or global assignments are isolated automatically. diff --git a/AGENTS.md b/AGENTS.md index cdfe0df9c13..d9cf698bf67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ - Use targeted `WHERE`/`JOIN` queries instead of loading all rows and filtering in JavaScript. Add indexes only when required by the query. - Change Drizzle schemas and regenerate migrations/snapshots; never edit snapshot JSON manually. -- Never mock the database in tests. Use `createConnection(":memory:")` and `migrate(db)`. +- Never mock the database in tests. Use `createMigratedConnection()` from `@bb/db/testing`, a real SQLite database copied from a template built once by the real migrations. Call `migrate(db)` directly only when testing migrations or an on-disk database. ## UI diff --git a/apps/server/src/services/hosts/host-environment-sync.test.ts b/apps/server/src/services/hosts/host-environment-sync.test.ts index 7d94adccc5b..ff632d11334 100644 --- a/apps/server/src/services/hosts/host-environment-sync.test.ts +++ b/apps/server/src/services/hosts/host-environment-sync.test.ts @@ -1,11 +1,5 @@ -import { - createConnection, - migrate, - noopNotifier, - setAppSettings, - updateHost, - upsertHost, -} from "@bb/db"; +import { noopNotifier, setAppSettings, updateHost, upsertHost } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { defaultAppSettings } from "@bb/domain"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -16,11 +10,10 @@ import { replaceMachineEnvironment } from "../machines/environment-settings.js"; import { HostEnvironmentSync } from "./host-environment-sync.js"; it("synchronizes configured variables to every connected host", async () => { - const db = createConnection(":memory:"); + const db = createMigratedConnection(); const dataDir = await mkdtemp(join(tmpdir(), "bb-machine-env-sync-")); const hub = new NotificationHub(); try { - migrate(db); setAppSettings(db, { ...defaultAppSettings, machineGitCredentialsEnabled: false, diff --git a/apps/server/src/services/hosts/host-environment.test.ts b/apps/server/src/services/hosts/host-environment.test.ts index a5e035a5eba..3c8cdc43f89 100644 --- a/apps/server/src/services/hosts/host-environment.test.ts +++ b/apps/server/src/services/hosts/host-environment.test.ts @@ -7,6 +7,7 @@ import { getHost, setAppSettings, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { copyFile, mkdtemp, @@ -153,10 +154,9 @@ it("gives every host user environment while forwarding automatic gh credentials }); it("forwards automatic gh credentials to every machine when the server has no local host daemon", async () => { - const db = createConnection(":memory:"); + const db = createMigratedConnection(); const dataDir = await mkdtemp(join(tmpdir(), "bb-serverless-env-")); try { - migrate(db); upsertHost(db, noopNotifier, { id: "remote", name: "Remote" }); const bin = join(dataDir, "bin"); await mkdir(bin); diff --git a/apps/server/src/services/machines/enrollments.test.ts b/apps/server/src/services/machines/enrollments.test.ts index 54caa2e6067..7f23823f4a6 100644 --- a/apps/server/src/services/machines/enrollments.test.ts +++ b/apps/server/src/services/machines/enrollments.test.ts @@ -2,7 +2,8 @@ import { eq } from "drizzle-orm"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createConnection, migrate, hosts } from "@bb/db"; +import { hosts } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMachineAuthService } from "../machine-auth.js"; import { createMachineEnrollmentService } from "./enrollments.js"; @@ -17,8 +18,7 @@ afterEach(async () => { async function harness() { const dataDir = await mkdtemp(join(tmpdir(), "bb-enrollments-test-")); - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); cleanup.push(async () => { db.$client.close(); await rm(dataDir, { recursive: true, force: true }); diff --git a/apps/server/src/services/machines/environment-storage.test.ts b/apps/server/src/services/machines/environment-storage.test.ts index b16997fbfb5..79b85b3e40a 100644 --- a/apps/server/src/services/machines/environment-storage.test.ts +++ b/apps/server/src/services/machines/environment-storage.test.ts @@ -7,9 +7,9 @@ import { projects, appSettingsValues, environmentVariables, - createConnection, - migrate, + type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { afterEach, beforeEach, expect, it } from "vitest"; import { setMachineEnvironmentVariable, @@ -19,11 +19,10 @@ import { replaceMachineEnvironment, } from "./environment-storage.js"; -let db: ReturnType; +let db: DbConnection; let dataDir: string; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); dataDir = await mkdtemp(join(tmpdir(), "bb-env-encryption-")); }); afterEach(async () => { @@ -47,8 +46,7 @@ it("stores encrypted values that survive a database reopen", async () => { ).toBe(0o600); } db.$client.close(); - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); db.insert(environmentVariables).values(persisted).run(); expect(readMachineEnvironment(db)).toEqual(rows); expect(await decryptMachineEnvironment(dataDir, rows[1]!)).toBe( diff --git a/apps/server/src/services/projects/attachments.test.ts b/apps/server/src/services/projects/attachments.test.ts index 8c6a68570a4..5e1a1892658 100644 --- a/apps/server/src/services/projects/attachments.test.ts +++ b/apps/server/src/services/projects/attachments.test.ts @@ -1,10 +1,6 @@ import type { PromptInput } from "@bb/domain"; -import { - createConnection, - getProjectAttachment, - migrate, - projects, -} from "@bb/db"; +import { getProjectAttachment, projects, type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { beforeEach } from "vitest"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -18,10 +14,9 @@ import { } from "./attachments.js"; const tempDirs: string[] = []; -let db: ReturnType; +let db: DbConnection; beforeEach(() => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); for (const id of ["proj_test", "proj_source", "proj_target"]) db.insert(projects) .values({ diff --git a/apps/server/test/app/host-shared-ports.test.ts b/apps/server/test/app/host-shared-ports.test.ts index fee02e97920..f8fb8a903e7 100644 --- a/apps/server/test/app/host-shared-ports.test.ts +++ b/apps/server/test/app/host-shared-ports.test.ts @@ -1,12 +1,11 @@ import { - createConnection, heartbeatSession, - migrate, noopNotifier, openSession, updateHost, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { HOST_DAEMON_PROTOCOL_VERSION, hostDaemonServerWsMessageSchema, @@ -28,8 +27,7 @@ import { } from "../helpers/test-app.js"; function setup(args: { enrolled?: boolean; online?: boolean } = {}) { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const hub = new NotificationHub(); const sharedPorts = new HostSharedPortCoordinator({ db, hub }); const host = upsertHost(db, noopNotifier, { diff --git a/apps/server/test/app/watch-interests.test.ts b/apps/server/test/app/watch-interests.test.ts index 5a0a8aa1ae8..a4712bbeca3 100644 --- a/apps/server/test/app/watch-interests.test.ts +++ b/apps/server/test/app/watch-interests.test.ts @@ -1,15 +1,14 @@ import { - createConnection, createEnvironment, createProject, createThread, environments, threads, - migrate, noopNotifier, updateThread, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { eq } from "drizzle-orm"; import { hostDaemonServerWsMessageSchema, @@ -21,8 +20,7 @@ import { WatchInterestCoordinator } from "../../src/ws/watch-interests.js"; import { createMockHubSocket } from "../helpers/mock-hub-socket.js"; function setup(queries?: string[]) { - const db = createConnection( - ":memory:", + const db = createMigratedConnection( queries ? { slowQueryThresholdMs: 0, @@ -34,7 +32,6 @@ function setup(queries?: string[]) { } : undefined, ); - migrate(db); const hub = new NotificationHub(); const watchInterests = new WatchInterestCoordinator({ db, hub }); const host = upsertHost(db, noopNotifier, { diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 56c035e674f..68731ff6186 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { AddressInfo } from "node:net"; import { - createConnection, + ensurePersonalProject, getAppSettings, getDisabledProviderIds, listRunningThreads, @@ -16,7 +16,7 @@ import { type DbConnection, } from "@bb/db"; import { defaultFeatureFlags } from "@bb/domain"; -import { initDb } from "../../src/db.js"; +import { createMigratedConnection } from "@bb/db/testing"; import { createApp } from "../../src/server.js"; import { PendingInteractionLifecycle } from "../../src/services/interactions/pending-interactions.js"; import { createMachineAuthService } from "../../src/services/machine-auth.js"; @@ -147,13 +147,10 @@ export function createTestDaemonHostKey( }); } -let migratedTemplate: Buffer | null = null; - export function createTestDb(): DbConnection { - if (migratedTemplate === null) { - migratedTemplate = initDb(":memory:").$client.serialize(); - } - return createConnection(migratedTemplate); + const db = createMigratedConnection(); + ensurePersonalProject(db); + return db; } export async function createTestAppHarness( diff --git a/apps/server/test/helpers/timeline-cache-fixture.ts b/apps/server/test/helpers/timeline-cache-fixture.ts index af17b313dd9..724441c2d77 100644 --- a/apps/server/test/helpers/timeline-cache-fixture.ts +++ b/apps/server/test/helpers/timeline-cache-fixture.ts @@ -14,11 +14,11 @@ import { createThread, getLatestThreadSequence, insertEvents, - migrate, noopNotifier, upsertHost, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; export type Random = () => number; @@ -45,8 +45,7 @@ let migratedImage: Buffer | null = null; function readMigratedImage(): Buffer { if (migratedImage === null) { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); migratedImage = db.$client.serialize(); db.$client.close(); } diff --git a/apps/server/test/in-memory-test-databases.test.ts b/apps/server/test/in-memory-test-databases.test.ts new file mode 100644 index 00000000000..f9de2239473 --- /dev/null +++ b/apps/server/test/in-memory-test-databases.test.ts @@ -0,0 +1,36 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; +import { expect, it } from "vitest"; + +const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); + +const FRESH_SCHEMA_EXCEPTIONS = new Set([ + "src/services/hosts/host-environment.test.ts", + "test/legacy-automations-export.test.ts", + "test/services/plugins/plugin-native-server-loading.test.ts", +]); + +function listTestSources(): string[] { + return ["src", "test"].flatMap((directory) => + readdirSync(join(PACKAGE_ROOT, directory), { recursive: true }) + .map((entry) => `${directory}/${String(entry).split(sep).join("/")}`) + .filter((path) => + directory === "test" ? path.endsWith(".ts") : path.endsWith(".test.ts"), + ), + ); +} + +it("builds in-memory test databases from the migrated template", () => { + const offenders = listTestSources().filter( + (path) => + !FRESH_SCHEMA_EXCEPTIONS.has(path) && + /createConnection\(\s*":memory:"/.test( + readFileSync(join(PACKAGE_ROOT, path), "utf8"), + ), + ); + + expect( + offenders, + 'Use createMigratedConnection() from "@bb/db/testing" instead of migrating a fresh in-memory database per test.', + ).toEqual([]); +}); diff --git a/apps/server/test/machine-auth.test.ts b/apps/server/test/machine-auth.test.ts index cf5d054d1ba..1435de82f41 100644 --- a/apps/server/test/machine-auth.test.ts +++ b/apps/server/test/machine-auth.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { eq } from "drizzle-orm"; import { authApiKeys, authUsers } from "@bb/db"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { initDb } from "../src/db.js"; +import { createMigratedConnection } from "@bb/db/testing"; import { createMachineAuthService } from "../src/services/machine-auth.js"; const tempDirs: string[] = []; @@ -24,7 +24,7 @@ async function makeTempDir(): Promise { async function createMachineAuthHarness() { const dataDir = await makeTempDir(); - const db = initDb(":memory:"); + const db = createMigratedConnection(); const machineAuth = await createMachineAuthService({ dataDir, db, diff --git a/apps/server/test/provider-corpus/corpus-harness.ts b/apps/server/test/provider-corpus/corpus-harness.ts index f08f0dc6f27..4b307f22df2 100644 --- a/apps/server/test/provider-corpus/corpus-harness.ts +++ b/apps/server/test/provider-corpus/corpus-harness.ts @@ -3,15 +3,14 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { - createConnection, createProject, getLatestThreadSequence, getThread, - migrate, noopNotifier, threads, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import { defaultFeatureFlags } from "@bb/domain"; import type { Thread } from "@bb/domain"; @@ -79,8 +78,7 @@ export function loadCorpusThreadIntoDb( corpusThread: CorpusThread, connection?: DbConnection, ): LoadedCorpusThread { - const db = connection ?? createConnection(":memory:"); - if (connection === undefined) migrate(db); + const db = connection ?? createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "provider-corpus-host", }); diff --git a/apps/server/test/provider-corpus/synthetic-thread.ts b/apps/server/test/provider-corpus/synthetic-thread.ts index 7d7c8da08ea..d0427d76603 100644 --- a/apps/server/test/provider-corpus/synthetic-thread.ts +++ b/apps/server/test/provider-corpus/synthetic-thread.ts @@ -1,13 +1,12 @@ import { - createConnection, createProject, createThread, deriveStoredEventItemFields, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import { encodeClientTurnRequestIdNumber, @@ -452,8 +451,7 @@ export interface SyntheticThread { } export function createSyntheticThread(minimumEvents: number): SyntheticThread { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "synthetic-host", }); diff --git a/apps/server/test/services/database-maintenance-sweep.test.ts b/apps/server/test/services/database-maintenance-sweep.test.ts index 77215ee47b2..36302c2e0f6 100644 --- a/apps/server/test/services/database-maintenance-sweep.test.ts +++ b/apps/server/test/services/database-maintenance-sweep.test.ts @@ -19,6 +19,7 @@ import { noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { ServerLogger } from "../../src/types.js"; import { runDatabaseMaintenanceSweep } from "../../src/services/system/periodic-sweeps.js"; import { testLogger } from "../helpers/test-app.js"; @@ -137,8 +138,7 @@ function buildFreelist(db: DbConnection): void { } function setupBusyDatabaseWithFreelist() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); markDatabaseBusy(db); buildFreelist(db); @@ -156,8 +156,7 @@ function setupBusyFileDatabaseWithFreelist(tempDatabase: TempDatabasePath) { describe("runDatabaseMaintenanceSweep", () => { it("drops deferred legacy tables on an idle maintenance pass", () => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); createDeferredLegacyTables(db); expect(listDeferredLegacyTables(db)).toEqual( [...TEST_DEFERRED_LEGACY_TABLE_NAMES].sort(), diff --git a/apps/server/test/services/entity-lookup.test.ts b/apps/server/test/services/entity-lookup.test.ts index c1680894e66..a4073c09bac 100644 --- a/apps/server/test/services/entity-lookup.test.ts +++ b/apps/server/test/services/entity-lookup.test.ts @@ -2,12 +2,10 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { closeSession, - createConnection, createEnvironment, createProject, createThread, hostDaemonSessions, - migrate, markProjectDeleted, noopNotifier, openSession, @@ -15,6 +13,7 @@ import { upsertHost, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Host, Project } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { ApiError } from "../../src/errors.js"; @@ -39,8 +38,7 @@ interface SetupResult { type ThrowingCallback = () => void; function setup(): SetupResult { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const hub = new NotificationHub(); const hostRow = upsertHost(db, noopNotifier, { id: "host_entity_lookup", diff --git a/apps/server/test/services/environments/environment-response.test.ts b/apps/server/test/services/environments/environment-response.test.ts index 241bbc857d0..89f46b724eb 100644 --- a/apps/server/test/services/environments/environment-response.test.ts +++ b/apps/server/test/services/environments/environment-response.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; import { - createConnection, - migrate, noopNotifier, updateHost, upsertHost, type EnvironmentRow, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { resolveEnvironmentWorkspaceDisplayKind, toEnvironmentResponse, @@ -14,8 +13,7 @@ import { } from "../../../src/services/environments/environment-response.js"; function setupDb() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); upsertHost(db, noopNotifier, { id: "host_1", name: "Host" }); return db; } diff --git a/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts b/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts index 6bf718647c9..c511876662f 100644 --- a/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts +++ b/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts @@ -1,7 +1,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { registerPluginCatalogRoutes } from "../../../src/routes/plugin-catalog.js"; @@ -27,8 +28,7 @@ describe("plugin catalog routes", () => { let dataDir: string; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); dataDir = await mkdtemp(join(tmpdir(), "bb-catalog-routes-")); }); diff --git a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts index 580ec563b6f..4a5ca72a650 100644 --- a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts +++ b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts @@ -2,16 +2,15 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - createConnection, getPluginMarketplace, listPluginMarketplaceIcons, markInstalledPluginRemoved, - migrate, upsertPluginMarketplace, upsertInstalledPlugin, type DbConnection, type PluginSourceIntent, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { CURATED_PLUGIN_MARKETPLACE_NAME, ROOT_PLUGIN_SOURCE_SELECTION, @@ -113,8 +112,7 @@ describe("plugin catalog service", () => { let dataDir: string; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); installedNames = []; installedCatalogEntries = []; dataDir = await mkdtemp(join(tmpdir(), "bb-catalog-data-")); diff --git a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts index 52078a1e22b..84b60d864e9 100644 --- a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts +++ b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts @@ -12,14 +12,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { - createConnection, getInstalledPlugin, getPluginMarketplace, getPluginMarketplaceIcon, - migrate, upsertInstalledPlugin, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createPluginCatalogService } from "../../../src/services/plugin-catalog/plugin-catalog-service.js"; import { refreshCuratedMarketplace } from "../../helpers/plugin-catalog.js"; @@ -89,8 +88,7 @@ describe("third-party marketplaces", () => { const restoreEnv: (() => void)[] = []; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); installedCatalogEntries = []; dataDir = await mkdtemp(join(tmpdir(), "bb-marketplace-data-")); cleanup.push(dataDir); diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 6c678d5638d..0e804652a9a 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -14,15 +14,14 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createConnection, getInstalledPluginRegistration, getPluginSettingsValues, listPluginSchedules, - migrate, setPluginSettingsValues, upsertPluginSchedule, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { PLUGIN_SDK_MAJOR, PLUGIN_SDK_VERSION } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -258,8 +257,7 @@ describe("builtin plugin reconciliation", () => { delete globals.__builtinFixtureLoads; delete globals.__packagedBuiltinLoads; delete globals.__hotBuiltinServerVersion; - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-builtin-plugins-")); }); diff --git a/apps/server/test/services/plugins/git-source-intent.test.ts b/apps/server/test/services/plugins/git-source-intent.test.ts index b393934d2a6..4400b021438 100644 --- a/apps/server/test/services/plugins/git-source-intent.test.ts +++ b/apps/server/test/services/plugins/git-source-intent.test.ts @@ -1,18 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - createConnection, - migrate, - upsertInstalledPlugin, - type DbConnection, -} from "@bb/db"; +import { upsertInstalledPlugin, type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { gitSelectorForRow } from "../../../src/services/plugins/git-source-intent.js"; describe("persisted git source intent", () => { let db: DbConnection; beforeEach(() => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); }); afterEach(() => db.$client.close()); diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts index 9ac4318cd58..6cb367ea198 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -4,12 +4,11 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, getInstalledPluginRegistration, - migrate, upsertInstalledPlugin, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { derivePluginId } from "@bb/domain"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -101,8 +100,7 @@ describe("store-installed official plugins", () => { beforeEach(async () => { delete globals.__builtinFixtureLoads; - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-community-plugins-")); }); diff --git a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts index 84faf1fc199..cb57e497637 100644 --- a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts +++ b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { encodeClientTurnRequestIdNumber } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -99,8 +100,7 @@ describe("plugin skills tier", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-skills-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -209,8 +209,7 @@ describe("plugin agent contributions reach thread runtime config", () => { }); it("isolates resolver failures and timeouts", async () => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const service = createPluginService({ aiServices: createAiServiceRegistry(), telemetry: createNoopTelemetryService(), diff --git a/apps/server/test/services/plugins/plugin-agent-tools.test.ts b/apps/server/test/services/plugins/plugin-agent-tools.test.ts index 3865d9e9ed7..cd4a547e2df 100644 --- a/apps/server/test/services/plugins/plugin-agent-tools.test.ts +++ b/apps/server/test/services/plugins/plugin-agent-tools.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { encodeClientTurnRequestIdNumber } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { RESERVED_AGENT_TOOL_NAMES } from "@get-bb/plugin-sdk/internal/host-policy"; @@ -66,8 +67,7 @@ describe("bb.agents.registerTool", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-tools-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -569,8 +569,7 @@ describe("bb.agents.contributeInstructions", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-instr-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), diff --git a/apps/server/test/services/plugins/plugin-background.test.ts b/apps/server/test/services/plugins/plugin-background.test.ts index 353fb3ba3b1..0eff36bd138 100644 --- a/apps/server/test/services/plugins/plugin-background.test.ts +++ b/apps/server/test/services/plugins/plugin-background.test.ts @@ -6,13 +6,12 @@ import { and, eq } from "drizzle-orm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { claimPluginScheduledRun, - createConnection, getInstalledPlugin, listPluginSchedules, - migrate, pluginSchedules, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; import { @@ -72,8 +71,7 @@ describe("plugin background services", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-bg-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -550,8 +548,7 @@ describe("plugin schedules", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-sched-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), diff --git a/apps/server/test/services/plugins/plugin-cache-prune.test.ts b/apps/server/test/services/plugins/plugin-cache-prune.test.ts index 36d76120fb9..234e14f3b6a 100644 --- a/apps/server/test/services/plugins/plugin-cache-prune.test.ts +++ b/apps/server/test/services/plugins/plugin-cache-prune.test.ts @@ -5,15 +5,14 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, createPluginArtifact, createPluginStateSnapshot, getInstalledPluginRegistration, listPluginArtifacts, - migrate, upsertInstalledPlugin, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { garbageCollectPluginArtifacts, @@ -84,8 +83,7 @@ describe("plugin cache pruning", () => { let gitRepoCache: string; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); dataDir = await mkdtemp(join(tmpdir(), "bb-plugin-prune-")); gitRepoCache = join( dataDir, @@ -383,8 +381,7 @@ describe("git plugin cache lifecycle", () => { } beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-cache-")); repo = join(workDir, "repo"); await mkdir(repo, { recursive: true }); diff --git a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts index 93cfcc1ecaa..1da123007d0 100644 --- a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts +++ b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts @@ -3,15 +3,14 @@ import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-reg import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createConnection, migrate } from "@bb/db"; import type { Logger } from "@bb/logger"; import { createPluginRuntime } from "../../../src/services/plugins/plugin-runtime.js"; import { testLogger } from "../../helpers/test-app.js"; import { createNoopTelemetryService } from "../../../src/services/system/telemetry.js"; +import { createMigratedConnection } from "@bb/db/testing"; async function createRuntime() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); return createPluginRuntime({ machineEnrollments: null, includedBuiltinNames: new Set(), diff --git a/apps/server/test/services/plugins/plugin-install-hook.test.ts b/apps/server/test/services/plugins/plugin-install-hook.test.ts index 0f353f4e968..24249cc52c6 100644 --- a/apps/server/test/services/plugins/plugin-install-hook.test.ts +++ b/apps/server/test/services/plugins/plugin-install-hook.test.ts @@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; import { @@ -58,8 +59,7 @@ describe("bb.onInstall", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-install-hook-")); service = createPluginService({ aiServices: createAiServiceRegistry(), diff --git a/apps/server/test/services/plugins/plugin-install.test.ts b/apps/server/test/services/plugins/plugin-install.test.ts index 4e9e7264258..f6023b05da5 100644 --- a/apps/server/test/services/plugins/plugin-install.test.ts +++ b/apps/server/test/services/plugins/plugin-install.test.ts @@ -22,14 +22,13 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createConnection, deleteInstalledPlugin, getInstalledPlugin, getInstalledPluginRegistration, listPluginArtifacts, - migrate, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { ROOT_PLUGIN_SOURCE_SELECTION } from "@bb/server-contract"; import type { Logger } from "@bb/logger"; import { scaffoldPlugin } from "@bb/templates/plugin-scaffold"; @@ -435,8 +434,7 @@ describe("plugin install flows", () => { let materializationCount: number; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-install-")); dataDir = join(workDir, "data"); afterArtifactPromoted = undefined; diff --git a/apps/server/test/services/plugins/plugin-mention-providers.test.ts b/apps/server/test/services/plugins/plugin-mention-providers.test.ts index 389ccc08e53..988bbef2b66 100644 --- a/apps/server/test/services/plugins/plugin-mention-providers.test.ts +++ b/apps/server/test/services/plugins/plugin-mention-providers.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { type PromptInput } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -666,8 +667,7 @@ describe("mention search time box", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-mention-timeout-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -745,8 +745,7 @@ describe("mention resolve time box", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-resolve-timeout-")); service = createPluginService({ aiServices: createAiServiceRegistry(), diff --git a/apps/server/test/services/plugins/plugin-replacement.test.ts b/apps/server/test/services/plugins/plugin-replacement.test.ts index 3e96dcff00e..1fe29eafc97 100644 --- a/apps/server/test/services/plugins/plugin-replacement.test.ts +++ b/apps/server/test/services/plugins/plugin-replacement.test.ts @@ -3,12 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, getPluginSettingsValues, - migrate, setPluginSettingsValues, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; import { @@ -56,8 +55,7 @@ describe("replacement plugins", () => { } beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-replacement-")); service = createPluginService({ aiServices: createAiServiceRegistry(), diff --git a/apps/server/test/services/plugins/plugin-safe-mode.test.ts b/apps/server/test/services/plugins/plugin-safe-mode.test.ts index 1d582bb0dbe..d4feb279fe8 100644 --- a/apps/server/test/services/plugins/plugin-safe-mode.test.ts +++ b/apps/server/test/services/plugins/plugin-safe-mode.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createConnection, migrate, type DbConnection } from "@bb/db"; +import { type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; import { @@ -90,8 +91,7 @@ describe("plugin safe mode", () => { } beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-safe-mode-")); service = createService(); await service.start(); @@ -200,8 +200,7 @@ describe("plugin safe mode", () => { it("keeps an included plugin running when its row kept catalog provenance", async () => { await service.stop(); db.$client.close(); - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); service = createService(false); await service.start(); await service.installOfficialPlugin("fixture"); diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts index fbacd73c591..aa03b782586 100644 --- a/apps/server/test/services/plugins/plugin-sdk.test.ts +++ b/apps/server/test/services/plugins/plugin-sdk.test.ts @@ -4,14 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createConnection, archiveThread, createThread, getThread, insertThreadPluginMetadata, - migrate, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -170,8 +169,7 @@ describe("plugin bb.sdk bind gate", () => { }); beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-sdk-test-")); sharedPorts.declareSharedPorts.mockClear(); sharedPorts.validateSharedPortDeclaration.mockClear(); diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 1aeda04cbc1..003a6d7b5d0 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -13,17 +13,16 @@ import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import semver from "semver"; import { - createConnection, createEnvironment, createProject, getInstalledPlugin, - migrate, noopNotifier, upsertHost, upsertInstalledPlugin, upsertPluginMarketplace, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { PLUGIN_SDK_VERSION, type SystemChangeKind } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { pluginListResponseSchema } from "@bb/server-contract"; @@ -114,8 +113,7 @@ describe("plugin service", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-test-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -1084,8 +1082,7 @@ describe("plugin service", () => { it("holds every plugin a hold names at start without running its factory or starting its services", async () => { const globals = globalThis as Record; - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const workDir = await mkdtemp(join(tmpdir(), "bb-plugin-hold-test-")); const fixture = { db, workDir }; let service = createTelemetryTrackedService([], fixture); @@ -1541,8 +1538,7 @@ describe("plugins-changed broadcast", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-notify-test-")); notifySystem = vi.fn<(changes: SystemChangeKind[]) => void>(); providerRegistry = createProviderRegistryService(); diff --git a/apps/server/test/services/plugins/plugin-settings-storage.test.ts b/apps/server/test/services/plugins/plugin-settings-storage.test.ts index 93bb04174da..fae0fba854b 100644 --- a/apps/server/test/services/plugins/plugin-settings-storage.test.ts +++ b/apps/server/test/services/plugins/plugin-settings-storage.test.ts @@ -15,12 +15,11 @@ import { join } from "node:path"; import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, getPluginSettingsValues, - migrate, setPluginSettingsValues, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { registerPluginRoutes } from "../../../src/routes/plugins.js"; import { createPluginInstallJobs } from "../../../src/services/plugins/plugin-install-jobs.js"; @@ -81,8 +80,7 @@ describe("plugin settings + storage", () => { let systemBroadcasts: string[][]; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-storage-test-")); dataDir = join(workDir, "data"); systemBroadcasts = []; diff --git a/apps/server/test/services/plugins/plugin-state-snapshot.test.ts b/apps/server/test/services/plugins/plugin-state-snapshot.test.ts index 46bfe7fb499..e8246ee1917 100644 --- a/apps/server/test/services/plugins/plugin-state-snapshot.test.ts +++ b/apps/server/test/services/plugins/plugin-state-snapshot.test.ts @@ -11,19 +11,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, createPluginArtifact, createPluginStateSnapshot, getInstalledPlugin, getPluginKvValue, listPluginArtifacts, listPluginStateSnapshots, - migrate, setPluginKvValue, setPluginStateSnapshotStatus, upsertInstalledPlugin, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { garbageCollectPluginArtifacts } from "../../../src/services/plugins/plugin-artifact-gc.js"; import { createPluginStateSnapshotOnDisk, @@ -36,8 +35,7 @@ describe("plugin activation snapshots and garbage collection", () => { let dataDir: string; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); dataDir = await mkdtemp(join(tmpdir(), "bb-plugin-snapshot-")); }); diff --git a/apps/server/test/services/plugins/plugin-update.test.ts b/apps/server/test/services/plugins/plugin-update.test.ts index b72f22d64da..f2cc3a443a4 100644 --- a/apps/server/test/services/plugins/plugin-update.test.ts +++ b/apps/server/test/services/plugins/plugin-update.test.ts @@ -18,7 +18,6 @@ import { promisify } from "node:util"; import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createConnection, getInstalledPlugin, getPluginKvValue, getInstalledPluginRegistration, @@ -26,13 +25,13 @@ import { listPluginSchedules, listPluginArtifacts, listPluginStateSnapshots, - migrate, setPluginSettingsValues, upsertPluginSchedule, upsertInstalledPlugin, upsertPluginMarketplace, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { Logger } from "@bb/logger"; import { registerPluginRoutes } from "../../../src/routes/plugins.js"; import { createPluginInstallJobs } from "../../../src/services/plugins/plugin-install-jobs.js"; @@ -90,8 +89,7 @@ async function commitPlugin( describe("plugin update scheduling", () => { it("waits one full interval when no plugins are eligible for update checks", async () => { const HOUR = 60 * 60 * 1_000; - const emptyDb = createConnection(":memory:"); - migrate(emptyDb); + const emptyDb = createMigratedConnection(); const scheduled: number[] = []; const emptyService = createPluginService({ aiServices: createAiServiceRegistry(), @@ -123,8 +121,7 @@ describe("plugin update scheduling", () => { it("defers a periodic update check while the server is moving", async () => { const HOUR = 60 * 60 * 1_000; - const frozenDb = createConnection(":memory:"); - migrate(frozenDb); + const frozenDb = createMigratedConnection(); const scheduled: { delayMs: number; onElapsed: () => Promise }[] = []; const notifySystem = vi.fn(); const frozenService = createPluginService({ @@ -173,8 +170,7 @@ describe("plugin update scheduling", () => { it("sweeps on start when a plugin was never checked, then waits out the interval across restarts", async () => { const HOUR = 60 * 60 * 1_000; - const schedulingDb = createConnection(":memory:"); - migrate(schedulingDb); + const schedulingDb = createMigratedConnection(); const schedulingWorkDir = await mkdtemp( join(tmpdir(), "bb-plugin-update-scheduling-"), ); @@ -334,8 +330,7 @@ describe("plugin update service and routes", () => { | undefined; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-update-")); repo = join(workDir, "repo"); await mkdir(repo, { recursive: true }); diff --git a/apps/server/test/services/plugins/prebuilt-server-loading.test.ts b/apps/server/test/services/plugins/prebuilt-server-loading.test.ts index 706fb71da2d..d049569d4d4 100644 --- a/apps/server/test/services/plugins/prebuilt-server-loading.test.ts +++ b/apps/server/test/services/plugins/prebuilt-server-loading.test.ts @@ -12,12 +12,8 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - createConnection, - migrate, - upsertInstalledPlugin, - type DbConnection, -} from "@bb/db"; +import { upsertInstalledPlugin, type DbConnection } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { PLUGIN_SDK_MAJOR, PLUGIN_SDK_VERSION } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createAiServiceRegistry } from "../../../src/services/ai/ai-service-registry.js"; @@ -68,8 +64,7 @@ describe("prebuilt server bundle loading", () => { let service: PluginService; beforeEach(async () => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-prebuilt-")); service = createPluginService({ aiServices: createAiServiceRegistry(), @@ -237,8 +232,7 @@ export default function plugin() { const linkedDataDir = join(workDir, "linked-data"); await mkdir(realDataDir); await symlink(realDataDir, linkedDataDir, "dir"); - const linkedDb = createConnection(":memory:"); - migrate(linkedDb); + const linkedDb = createMigratedConnection(); const linkedService = createPluginService({ aiServices: createAiServiceRegistry(), telemetry: createNoopTelemetryService(), diff --git a/apps/server/test/services/prompt-history.test.ts b/apps/server/test/services/prompt-history.test.ts index c00abf1fbe2..ecb66d0bc0a 100644 --- a/apps/server/test/services/prompt-history.test.ts +++ b/apps/server/test/services/prompt-history.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest"; import { archiveThread, - createConnection, createQueuedThreadMessage, createProject, createPromptHistoryEntry, createThread, markThreadDeleted, - migrate, noopNotifier, promptHistoryEntries, upsertHost, + type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { PromptHistoryScope, PromptInput } from "@bb/domain"; import { listProjectPromptHistory, @@ -20,11 +20,9 @@ import { } from "../../src/services/prompt-history.js"; import { textInput } from "../helpers/prompt-input.js"; -type TestDb = ReturnType; - interface InsertPromptHistoryEntryArgs { createdAt: number; - db: TestDb; + db: DbConnection; input: PromptInput[]; projectId: string; requestSequence: number; @@ -33,8 +31,7 @@ interface InsertPromptHistoryEntryArgs { } function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/telemetry.test.ts b/apps/server/test/services/telemetry.test.ts index 925490d07b5..0e24ff2d755 100644 --- a/apps/server/test/services/telemetry.test.ts +++ b/apps/server/test/services/telemetry.test.ts @@ -1,12 +1,8 @@ import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - createConnection, - migrate, - getAppSettings, - setAppSettings, -} from "@bb/db"; +import { getAppSettings, setAppSettings } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { defaultAppSettings } from "@bb/domain"; import { withTestHarness } from "../helpers/test-app.js"; import { DEFAULTS } from "@bb/config/defaults"; @@ -235,8 +231,7 @@ describe("telemetry service", () => { }); it("honors persisted opt-out at startup and changes without restarting", async () => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); try { setAppSettings(db, { ...getAppSettings(db), telemetryEnabled: false }); const args = { diff --git a/apps/server/test/services/threads/conversation-outline-performance.test.ts b/apps/server/test/services/threads/conversation-outline-performance.test.ts index f7eef9cd31b..255c4cc6456 100644 --- a/apps/server/test/services/threads/conversation-outline-performance.test.ts +++ b/apps/server/test/services/threads/conversation-outline-performance.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { threadScope, turnScope, type Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, deleteThreadEventSuffixInTransaction, @@ -9,12 +8,12 @@ import { getThread, getThreadConversationOutlineRecord, insertEvents, - migrate, noopNotifier, threads, upsertHost, type SlowDbQueryLogFields, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { eq } from "drizzle-orm"; import { buildThreadConversationOutline, @@ -23,7 +22,7 @@ import { function setup(status: Thread["status"] = "starting") { const queries: SlowDbQueryLogFields[] = []; - const db = createConnection(":memory:", { + const db = createMigratedConnection({ slowQueryThresholdMs: 0, slowQueryLogger: { info(fields) { @@ -31,7 +30,6 @@ function setup(status: Thread["status"] = "starting") { }, }, }); - migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/lifecycle-outcome.test.ts b/apps/server/test/services/threads/lifecycle-outcome.test.ts index 53e57aa76aa..6a5f950c8cd 100644 --- a/apps/server/test/services/threads/lifecycle-outcome.test.ts +++ b/apps/server/test/services/threads/lifecycle-outcome.test.ts @@ -2,17 +2,16 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, - createConnection, createEnvironment, createProject, createThread, getThread, - migrate, noopNotifier, openSession, upsertHost, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { changedMessageSchema, formatClientTurnRequestIdSuffix, @@ -116,8 +115,7 @@ interface Setup { } function setup(status: ThreadStatus): Setup { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const hub = new NotificationHub(); const host = upsertHost(db, noopNotifier, { id: "host-lifecycle-outcome", diff --git a/apps/server/test/services/threads/stored-event-decode-cache.test.ts b/apps/server/test/services/threads/stored-event-decode-cache.test.ts index ed4d4e2fd14..3575dcc99b8 100644 --- a/apps/server/test/services/threads/stored-event-decode-cache.test.ts +++ b/apps/server/test/services/threads/stored-event-decode-cache.test.ts @@ -5,17 +5,16 @@ import { turnScope, } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, listStoredEventRows, - migrate, noopNotifier, upsertHost, type DbConnection, type StoredEventRow, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { clearStoredEventDecodeCache, decodeStoredEventRowCached, @@ -37,8 +36,7 @@ function agentMessageData(text: string): string { } beforeAll(() => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "decode-host" }); const { project } = createProject(db, noopNotifier, { name: "decode-project", diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 61d76affe5f..0e7c68d8a6c 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, closeSession, - createConnection, createEnvironment, createProject, createQueuedThreadMessage, @@ -12,7 +11,6 @@ import { hostDaemonSessions, listQueuedThreadMessages, listThreadsWithPendingInteractionState, - migrate, noopNotifier, openSession, setQueuedThreadMessageFailureReason, @@ -20,6 +18,7 @@ import { type DbConnection, type ThreadWithPendingInteractionState, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { formatClientTurnRequestIdSuffix, threadScope, @@ -126,8 +125,7 @@ function turnRequestData({ } function setup(): SetupResult { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const hub = new NotificationHub(); const host = upsertHost(db, noopNotifier, { id: "host-runtime-display", diff --git a/apps/server/test/services/threads/timeline-context-clear.test.ts b/apps/server/test/services/threads/timeline-context-clear.test.ts index 1435e9b8488..1c7584ea5ff 100644 --- a/apps/server/test/services/threads/timeline-context-clear.test.ts +++ b/apps/server/test/services/threads/timeline-context-clear.test.ts @@ -7,16 +7,15 @@ import { type Thread, } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, listEvents, - migrate, noopNotifier, upsertHost, type DbConnection, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { buildThreadConversationOutline, buildThreadTimelineWithProfile, @@ -60,8 +59,7 @@ function clientTurnRequestedEvent( } function setup(): { db: DbConnection; thread: Thread } { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-event-budget.test.ts b/apps/server/test/services/threads/timeline-event-budget.test.ts index 95a5acc6612..dfa674615ec 100644 --- a/apps/server/test/services/threads/timeline-event-budget.test.ts +++ b/apps/server/test/services/threads/timeline-event-budget.test.ts @@ -23,10 +23,10 @@ import { createProject, createThread, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import type { ThreadTimelineResponse, @@ -117,8 +117,7 @@ function setup(connection?: DbConnection): { db: DbConnection; thread: Thread; } { - const db = connection ?? createConnection(":memory:"); - if (connection === undefined) migrate(db); + const db = connection ?? createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-head-state.test.ts b/apps/server/test/services/threads/timeline-head-state.test.ts index 4fecb57e741..6b99ac4612b 100644 --- a/apps/server/test/services/threads/timeline-head-state.test.ts +++ b/apps/server/test/services/threads/timeline-head-state.test.ts @@ -6,14 +6,13 @@ import { } from "@bb/domain"; import type { ClientTurnRequestId, Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import { buildThreadTimelineWithProfile } from "../../../src/services/threads/timeline.js"; @@ -30,8 +29,7 @@ const requestId = (value: number): ClientTurnRequestId => encodeClientTurnRequestIdNumber({ value }); function setup(): { db: DbConnection; thread: Thread } { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index 99acf541857..8b2aaf6132e 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -7,16 +7,15 @@ import { } from "@bb/domain"; import type { ClientTurnRequestId, Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, getLatestThreadSequence, insertEvents, - migrate, migrateNextLegacyImageGenerationOutput, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { LOCAL_WORKFLOW_TASK_TYPE } from "@bb/domain"; import type { DbConnection } from "@bb/db"; import type { @@ -50,8 +49,7 @@ function requestId(value: number): ClientTurnRequestId { } function setup(): { db: DbConnection; thread: Thread } { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-parented-pagination.test.ts b/apps/server/test/services/threads/timeline-parented-pagination.test.ts index 791600babeb..c904c860b00 100644 --- a/apps/server/test/services/threads/timeline-parented-pagination.test.ts +++ b/apps/server/test/services/threads/timeline-parented-pagination.test.ts @@ -6,14 +6,13 @@ import { } from "@bb/domain"; import type { ClientTurnRequestId, Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import type { TimelineRow } from "@bb/server-contract"; import { buildThreadTimelineWithProfile } from "../../../src/services/threads/timeline.js"; @@ -34,8 +33,7 @@ interface SetupResult { } function setup(): SetupResult { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-provider-input.test.ts b/apps/server/test/services/threads/timeline-provider-input.test.ts index 9a65b644fb5..6f657f26e21 100644 --- a/apps/server/test/services/threads/timeline-provider-input.test.ts +++ b/apps/server/test/services/threads/timeline-provider-input.test.ts @@ -6,14 +6,13 @@ import { } from "@bb/domain"; import type { Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import type { TimelineRow } from "@bb/server-contract"; import { buildThreadTimelineWithProfile } from "../../../src/services/threads/timeline.js"; @@ -24,8 +23,7 @@ const PROCESS_EVENT = 'Process completed successfully'; function setup(): { db: DbConnection; thread: Thread } { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts b/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts index 4514f0ba158..d633f8417c7 100644 --- a/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts +++ b/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts @@ -7,14 +7,13 @@ import { } from "@bb/domain"; import type { Thread } from "@bb/domain"; import { - createConnection, createProject, createThread, insertEvents, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import type { DbConnection } from "@bb/db"; import type { TimelinePaginationCursor, @@ -43,8 +42,7 @@ const execution = { type EventInput = Parameters[2][number]; function setup(): { db: DbConnection; thread: Thread } { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/setup/migrated-db-template.ts b/apps/server/test/setup/migrated-db-template.ts new file mode 100644 index 00000000000..0b10aa96344 --- /dev/null +++ b/apps/server/test/setup/migrated-db-template.ts @@ -0,0 +1,3 @@ +import { setupMigratedConnectionTemplate } from "@bb/db/testing"; + +export default setupMigratedConnectionTemplate; diff --git a/apps/server/test/system/event-loop-stall-monitor.test.ts b/apps/server/test/system/event-loop-stall-monitor.test.ts index 1cdfa5e8992..2114083b4fe 100644 --- a/apps/server/test/system/event-loop-stall-monitor.test.ts +++ b/apps/server/test/system/event-loop-stall-monitor.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { console as inspectorConsole } from "node:inspector"; -import { createConnection, migrate, setExperiments } from "@bb/db"; +import { setExperiments } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { startGatedPerformanceDiagnostics } from "../../src/services/system/performance-diagnostics.js"; import { NotificationHub } from "../../src/ws/hub.js"; @@ -403,8 +404,7 @@ describe("event loop stall monitor", () => { "identifies a slow CPU profile %s as a candidate in stall logs", async (phase) => { installHistogram({ maxDelayMs: 2_000, meanDelayMs: 25, p99DelayMs: 450 }); - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); setExperiments(db, { performanceDiagnostics: true }); const dataDir = await mkdtemp(join(tmpdir(), "bb-profile-attribution-")); const logger = { info: vi.fn(), warn: vi.fn() }; diff --git a/apps/server/test/system/performance-diagnostics.test.ts b/apps/server/test/system/performance-diagnostics.test.ts index fd11984b618..97f88e8341a 100644 --- a/apps/server/test/system/performance-diagnostics.test.ts +++ b/apps/server/test/system/performance-diagnostics.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout } from "node:timers/promises"; -import { createConnection, migrate, setExperiments } from "@bb/db"; +import { setExperiments } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { describe, expect, it } from "vitest"; import { NotificationHub } from "../../src/ws/hub.js"; import { startGatedPerformanceDiagnostics } from "../../src/services/system/performance-diagnostics.js"; @@ -17,8 +18,7 @@ describe("performance diagnostics gates", () => { it.each([false, true])( "requires launch permission %s as well as the live experiment", async (allowed) => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const dataDir = await mkdtemp(join(tmpdir(), "bb-perf-gates-")); const hub = new NotificationHub(); const messages: string[] = []; diff --git a/apps/server/test/threads/thread-create-helpers.test.ts b/apps/server/test/threads/thread-create-helpers.test.ts index 2bb91603adf..7dc5a6c7001 100644 --- a/apps/server/test/threads/thread-create-helpers.test.ts +++ b/apps/server/test/threads/thread-create-helpers.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from "vitest"; import { - createConnection, createEnvironment, createProject, createThreadSection, deleteThreadSection, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { DEFAULT_MANAGED_BRANCH_PREFIX } from "@bb/domain"; import { ApiError } from "../../src/errors.js"; import { @@ -96,9 +95,8 @@ describe("createThreadRecord", () => { it.each([{}, { sendAt: Date.now() + 60_000 }])( "preserves placement at creation for %j", (mode) => { - const db = createConnection(":memory:"); + const db = createMigratedConnection(); try { - migrate(db); const host = upsertHost(db, noopNotifier, { name: "Test" }); const { project } = createProject(db, noopNotifier, { name: "Test", @@ -142,9 +140,8 @@ describe("createThreadRecord", () => { ); it("returns section_not_found when the section is stale by create time", () => { - const db = createConnection(":memory:"); + const db = createMigratedConnection(); try { - migrate(db); const deps = { db, hub: noopNotifier }; const host = upsertHost(db, noopNotifier, { name: "Test Host", diff --git a/apps/server/test/threads/thread-data.test.ts b/apps/server/test/threads/thread-data.test.ts index 5872d88dcd1..063094d3e2d 100644 --- a/apps/server/test/threads/thread-data.test.ts +++ b/apps/server/test/threads/thread-data.test.ts @@ -1,18 +1,16 @@ import { describe, expect, it } from "vitest"; import { - createConnection, createProject, createThread, listStoredEventRows, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { parseStoredEvent } from "../../src/services/threads/thread-data.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/threads/thread-parent.test.ts b/apps/server/test/threads/thread-parent.test.ts index 4ab65a358e3..2bfa591c2ad 100644 --- a/apps/server/test/threads/thread-parent.test.ts +++ b/apps/server/test/threads/thread-parent.test.ts @@ -1,11 +1,5 @@ -import { - createConnection, - createProject, - createThread, - migrate, - noopNotifier, - upsertHost, -} from "@bb/db"; +import { createProject, createThread, noopNotifier, upsertHost } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { describe, expect, it } from "vitest"; import { ApiError } from "../../src/errors.js"; import { @@ -16,8 +10,7 @@ import { type ThrowingCallback = () => void; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/test/threads/thread-provisioning-state.test.ts b/apps/server/test/threads/thread-provisioning-state.test.ts index 55f78617d6d..d1a6156cb76 100644 --- a/apps/server/test/threads/thread-provisioning-state.test.ts +++ b/apps/server/test/threads/thread-provisioning-state.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from "vitest"; import { - createConnection, createEnvironment, createProject, createThread, - migrate, noopNotifier, upsertHost, } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { getThreadProvisionContext } from "../../src/services/threads/thread-startup-store.js"; import { requestThreadProvision } from "../../src/services/threads/thread-provisioning.js"; import { NotificationHub } from "../../src/ws/hub.js"; @@ -15,8 +14,7 @@ import { assertPromptHistoryForTurnRequest } from "../helpers/prompt-history.js" import { textInput } from "../helpers/prompt-input.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", }); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 1061edd2b83..82493b70245 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -7,6 +7,7 @@ export default defineWorkspaceTestConfig({ test: { silent: "passed-only", testTimeout: 15_000, + globalSetup: ["test/setup/migrated-db-template.ts"], setupFiles: [ "test/setup/stored-event-decode-freeze.ts", "test/setup/warm-test-harness.ts", diff --git a/packages/db/package.json b/packages/db/package.json index e0967bada61..77c4869f5b5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,6 +12,11 @@ "source": "./src/internal-environment-lifecycle.ts", "types": "./src/internal-environment-lifecycle.ts", "default": "./src/internal-environment-lifecycle.ts" + }, + "./testing": { + "source": "./src/testing.ts", + "types": "./src/testing.ts", + "default": "./src/testing.ts" } }, "types": "./src/index.ts", diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts new file mode 100644 index 00000000000..32bd935664f --- /dev/null +++ b/packages/db/src/testing.ts @@ -0,0 +1,55 @@ +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createConnection } from "./connection.js"; +import type { CreateConnectionOptions, DbConnection } from "./connection.js"; +import { migrate } from "./migrate.js"; + +const TEMPLATE_PATH_ENV = "BB_TEST_MIGRATED_DB_TEMPLATE"; + +let migratedTemplate: Buffer | null = null; + +function buildMigratedTemplate(): Buffer { + const db = createConnection(":memory:"); + try { + migrate(db); + return db.$client.serialize(); + } finally { + db.$client.close(); + } +} + +function getMigratedTemplate(): Buffer { + if (migratedTemplate === null) { + const templatePath = process.env[TEMPLATE_PATH_ENV]; + migratedTemplate = + templatePath === undefined + ? buildMigratedTemplate() + : readFileSync(templatePath); + } + + return migratedTemplate; +} + +export function prepareMigratedConnectionTemplate(): void { + getMigratedTemplate(); +} + +export function createMigratedConnection( + options: CreateConnectionOptions = {}, +): DbConnection { + return createConnection(getMigratedTemplate(), options); +} + +export function setupMigratedConnectionTemplate(): () => void { + const templatePath = join( + tmpdir(), + `bb-migrated-db-template-${process.pid}.sqlite`, + ); + writeFileSync(templatePath, buildMigratedTemplate()); + process.env[TEMPLATE_PATH_ENV] = templatePath; + return () => { + delete process.env[TEMPLATE_PATH_ENV]; + rmSync(templatePath, { force: true }); + }; +} diff --git a/packages/db/test/data/app-settings.test.ts b/packages/db/test/data/app-settings.test.ts index 93ef942f03e..e6a40aa73b0 100644 --- a/packages/db/test/data/app-settings.test.ts +++ b/packages/db/test/data/app-settings.test.ts @@ -7,7 +7,7 @@ import { setAppSettings, type DbConnection, } from "../../src/index.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; describe("app settings data", () => { let db: DbConnection; diff --git a/packages/db/test/data/archived-thread-teardown.test.ts b/packages/db/test/data/archived-thread-teardown.test.ts index e969c48bf8f..6ab67eabff1 100644 --- a/packages/db/test/data/archived-thread-teardown.test.ts +++ b/packages/db/test/data/archived-thread-teardown.test.ts @@ -11,7 +11,7 @@ import { } from "../../src/data/threads.js"; import { noopNotifier } from "../../src/notifier.js"; import { threads } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; import type { ThreadStatus } from "@bb/domain"; import type { TerminalSessionStatus } from "@bb/domain"; diff --git a/packages/db/test/data/completed-event-output-migration.test.ts b/packages/db/test/data/completed-event-output-migration.test.ts index 93b7f291b2c..81441721e18 100644 --- a/packages/db/test/data/completed-event-output-migration.test.ts +++ b/packages/db/test/data/completed-event-output-migration.test.ts @@ -27,7 +27,7 @@ import { retainedEventOutputs, threadPruningWork, } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/environment-lifecycle.test.ts b/packages/db/test/data/environment-lifecycle.test.ts index 5af7e60e4f1..d2260fa67ad 100644 --- a/packages/db/test/data/environment-lifecycle.test.ts +++ b/packages/db/test/data/environment-lifecycle.test.ts @@ -21,7 +21,7 @@ import { import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; import { withWriteAfterFirstRead } from "../helpers/interleave.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/environments.test.ts b/packages/db/test/data/environments.test.ts index 19e889df7cd..9ec078d29e9 100644 --- a/packages/db/test/data/environments.test.ts +++ b/packages/db/test/data/environments.test.ts @@ -15,7 +15,7 @@ import { import { environments } from "../../src/schema.js"; import { createProject } from "../../src/data/projects.js"; import { updateHost, upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/event-rewrite-generation.test.ts b/packages/db/test/data/event-rewrite-generation.test.ts index 50486c62368..918bea5f1fa 100644 --- a/packages/db/test/data/event-rewrite-generation.test.ts +++ b/packages/db/test/data/event-rewrite-generation.test.ts @@ -19,7 +19,7 @@ import { createThread } from "../../src/data/threads.js"; import { noopNotifier } from "../../src/notifier.js"; import { COMPLETED_EVENT_OUTPUT_TRUNCATION_THRESHOLD_CHARS } from "../../src/retained-event-output.js"; import { events } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; interface RewriteCase { between?: (threadId: string) => InsertEventInput[]; diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index c269300be96..5e96ebcf0a8 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -74,7 +74,7 @@ import type { } from "../../src/data/events.js"; import { upsertHost } from "../../src/data/hosts.js"; import type { DbConnection } from "../../src/connection.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/hosts.test.ts b/packages/db/test/data/hosts.test.ts index fe02f6bc10b..2766e92a8ee 100644 --- a/packages/db/test/data/hosts.test.ts +++ b/packages/db/test/data/hosts.test.ts @@ -9,7 +9,7 @@ import { updateHost, upsertHost, } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/machines.test.ts b/packages/db/test/data/machines.test.ts index 78d0abb46ca..889f0d2119e 100644 --- a/packages/db/test/data/machines.test.ts +++ b/packages/db/test/data/machines.test.ts @@ -14,7 +14,7 @@ import { createProject } from "../../src/data/projects.js"; import { archiveThread, createThread } from "../../src/data/threads.js"; import { noopNotifier } from "../../src/notifier.js"; import { environments, threads } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/maintenance.test.ts b/packages/db/test/data/maintenance.test.ts index 12210c85a98..5a5072b0391 100644 --- a/packages/db/test/data/maintenance.test.ts +++ b/packages/db/test/data/maintenance.test.ts @@ -23,7 +23,7 @@ import { import { upsertHost } from "../../src/data/hosts.js"; import { createProject } from "../../src/data/projects.js"; import { createThread, markThreadDeleted } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; const TEST_INCREMENTAL_VACUUM_MAX_PAGES = 128; diff --git a/packages/db/test/data/pending-interactions.test.ts b/packages/db/test/data/pending-interactions.test.ts index 0cb2080a505..c738973cd98 100644 --- a/packages/db/test/data/pending-interactions.test.ts +++ b/packages/db/test/data/pending-interactions.test.ts @@ -12,7 +12,7 @@ import { setPendingInteractionResolved, } from "../../src/data/pending-interactions.js"; import { createThread } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/plugin-persistence.test.ts b/packages/db/test/data/plugin-persistence.test.ts index 6b805f98f64..a1d413a64ab 100644 --- a/packages/db/test/data/plugin-persistence.test.ts +++ b/packages/db/test/data/plugin-persistence.test.ts @@ -14,7 +14,7 @@ import { type DbConnection, } from "../../src/index.js"; import type { UpsertInstalledPluginInput } from "../../src/data/plugins.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; describe("normalized plugin persistence", () => { let db: DbConnection; diff --git a/packages/db/test/data/project-execution-defaults.test.ts b/packages/db/test/data/project-execution-defaults.test.ts index 1839d229643..580a36989a8 100644 --- a/packages/db/test/data/project-execution-defaults.test.ts +++ b/packages/db/test/data/project-execution-defaults.test.ts @@ -6,7 +6,7 @@ import { upsertProjectExecutionDefaults, } from "../../src/data/project-execution-defaults.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/project-sources.test.ts b/packages/db/test/data/project-sources.test.ts index ba2f2ddb196..4cb7ba351e7 100644 --- a/packages/db/test/data/project-sources.test.ts +++ b/packages/db/test/data/project-sources.test.ts @@ -13,7 +13,7 @@ import { } from "../../src/data/project-sources.js"; import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/projects.test.ts b/packages/db/test/data/projects.test.ts index 0b484d1fd3b..f6b8c7822dd 100644 --- a/packages/db/test/data/projects.test.ts +++ b/packages/db/test/data/projects.test.ts @@ -13,7 +13,7 @@ import { setProjectGitRemoteUrlIfMissing, } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/queued-message-waits.test.ts b/packages/db/test/data/queued-message-waits.test.ts index bd0f3eda009..370e89bc0dc 100644 --- a/packages/db/test/data/queued-message-waits.test.ts +++ b/packages/db/test/data/queued-message-waits.test.ts @@ -30,7 +30,7 @@ import { deleteThread, } from "../../src/data/threads.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function textInput(text: string): PromptInput[] { return [{ type: "text", text, mentions: [] }]; diff --git a/packages/db/test/data/queued-thread-messages.test.ts b/packages/db/test/data/queued-thread-messages.test.ts index ee95bd9716d..e6bbe7cea3d 100644 --- a/packages/db/test/data/queued-thread-messages.test.ts +++ b/packages/db/test/data/queued-thread-messages.test.ts @@ -24,7 +24,7 @@ import { import { createProject } from "../../src/data/projects.js"; import { createThread } from "../../src/data/threads.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function textInput(text: string): PromptInput[] { return [{ type: "text", text, mentions: [] }]; diff --git a/packages/db/test/data/retained-event-outputs.test.ts b/packages/db/test/data/retained-event-outputs.test.ts index f86f9aef90c..f4c6c7e5323 100644 --- a/packages/db/test/data/retained-event-outputs.test.ts +++ b/packages/db/test/data/retained-event-outputs.test.ts @@ -22,7 +22,7 @@ import { } from "../../src/retained-event-output.js"; import { createThread } from "../../src/data/threads.js"; import { noopNotifier } from "../../src/notifier.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; import type { CreateConnectionOptions, SlowDbQueryLogFields, diff --git a/packages/db/test/data/server-move.test.ts b/packages/db/test/data/server-move.test.ts index 3f7adda231b..663345314e5 100644 --- a/packages/db/test/data/server-move.test.ts +++ b/packages/db/test/data/server-move.test.ts @@ -21,7 +21,7 @@ import { } from "../../src/index.js"; import type { UpsertInstalledPluginInput } from "../../src/data/plugins.js"; import { installedPlugins } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; const SOURCE_ROOT = "/home/me/.bb"; const TARGET_ROOT = "/home/me/.bb-machines/old-server"; diff --git a/packages/db/test/data/sessions.test.ts b/packages/db/test/data/sessions.test.ts index ee70be3d281..a4099211c80 100644 --- a/packages/db/test/data/sessions.test.ts +++ b/packages/db/test/data/sessions.test.ts @@ -11,7 +11,7 @@ import { } from "../../src/data/sessions.js"; import { getHost, upsertHost } from "../../src/data/hosts.js"; import { hostDaemonSessions } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/sweeps.test.ts b/packages/db/test/data/sweeps.test.ts index deef407b060..1d53443a9d7 100644 --- a/packages/db/test/data/sweeps.test.ts +++ b/packages/db/test/data/sweeps.test.ts @@ -7,7 +7,7 @@ import { upsertHost } from "../../src/data/hosts.js"; import { createProject } from "../../src/data/projects.js"; import { openSession } from "../../src/data/sessions.js"; import { hostDaemonSessions } from "../../src/schema.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/terminal-sessions.test.ts b/packages/db/test/data/terminal-sessions.test.ts index 4ccf90dc996..d4c1a87111d 100644 --- a/packages/db/test/data/terminal-sessions.test.ts +++ b/packages/db/test/data/terminal-sessions.test.ts @@ -12,7 +12,7 @@ import { upsertHost } from "../../src/data/hosts.js"; import { createProject } from "../../src/data/projects.js"; import { openSession } from "../../src/data/sessions.js"; import { createThread } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; type TestDb = ReturnType; type TestHost = ReturnType; diff --git a/packages/db/test/data/thread-count.test.ts b/packages/db/test/data/thread-count.test.ts index b937adfe480..87175d148e0 100644 --- a/packages/db/test/data/thread-count.test.ts +++ b/packages/db/test/data/thread-count.test.ts @@ -9,7 +9,7 @@ import { createThread, markThreadDeleted, } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; /** * Two hosts, two projects and threads spread across both, so every grouping diff --git a/packages/db/test/data/thread-lifecycle-ownership.test.ts b/packages/db/test/data/thread-lifecycle-ownership.test.ts index af93c9a5f72..2a9ff09a802 100644 --- a/packages/db/test/data/thread-lifecycle-ownership.test.ts +++ b/packages/db/test/data/thread-lifecycle-ownership.test.ts @@ -13,7 +13,7 @@ import { deleteThread, getThread, } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/thread-lifecycle.test.ts b/packages/db/test/data/thread-lifecycle.test.ts index 7713899c923..4816bb2bb30 100644 --- a/packages/db/test/data/thread-lifecycle.test.ts +++ b/packages/db/test/data/thread-lifecycle.test.ts @@ -15,7 +15,7 @@ import { import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; import { withWriteAfterFirstRead } from "../helpers/interleave.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/thread-plugin-metadata.test.ts b/packages/db/test/data/thread-plugin-metadata.test.ts index e24f581ed41..f822b878c87 100644 --- a/packages/db/test/data/thread-plugin-metadata.test.ts +++ b/packages/db/test/data/thread-plugin-metadata.test.ts @@ -19,7 +19,7 @@ import { } from "../../src/data/thread-plugin-metadata.js"; import { noopNotifier } from "../../src/notifier.js"; import { upsertHost } from "../../src/data/hosts.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup(name: string) { const db = createMigratedConnection(); diff --git a/packages/db/test/data/thread-pruning.test.ts b/packages/db/test/data/thread-pruning.test.ts index d7b2957c2c3..d403859221c 100644 --- a/packages/db/test/data/thread-pruning.test.ts +++ b/packages/db/test/data/thread-pruning.test.ts @@ -26,7 +26,7 @@ import { import { advanceLiveEventPruning } from "../../src/data/resolved-item-pruning.js"; import { getThreadEventRewriteGeneration } from "../../src/data/event-rewrite-generation.js"; import { THREAD_CONTEXT_CLEAR_OPERATION, turnScope } from "@bb/domain"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/thread-running.test.ts b/packages/db/test/data/thread-running.test.ts index 9d1e533be43..b9eac2f95ae 100644 --- a/packages/db/test/data/thread-running.test.ts +++ b/packages/db/test/data/thread-running.test.ts @@ -9,7 +9,7 @@ import { listRunningThreads, markThreadDeleted, } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/thread-search-ranking.test.ts b/packages/db/test/data/thread-search-ranking.test.ts index b465764cd17..6641d594afa 100644 --- a/packages/db/test/data/thread-search-ranking.test.ts +++ b/packages/db/test/data/thread-search-ranking.test.ts @@ -9,7 +9,7 @@ import { searchThreadsWithPendingInteractionState, upsertThreadSearchSegments, } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; interface FixtureThread { title: string | null; diff --git a/packages/db/test/data/thread-search.test.ts b/packages/db/test/data/thread-search.test.ts index 01efdf00c47..577cfcb7573 100644 --- a/packages/db/test/data/thread-search.test.ts +++ b/packages/db/test/data/thread-search.test.ts @@ -23,7 +23,7 @@ import { updateThread, upsertThreadSearchSegments, } from "../../src/data/threads.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; interface SetupResult { db: ReturnType; diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index d04a06b57ea..dfff89bfeef 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -40,7 +40,7 @@ import { import { createProject, markProjectDeleted } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; import { createEnvironment } from "../../src/data/environments.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; function setup() { const db = createMigratedConnection(); diff --git a/packages/db/test/data/ui-preferences.test.ts b/packages/db/test/data/ui-preferences.test.ts index 70cee552d2b..17061386d32 100644 --- a/packages/db/test/data/ui-preferences.test.ts +++ b/packages/db/test/data/ui-preferences.test.ts @@ -5,7 +5,7 @@ import { replaceStoredUiPreference, type DbConnection, } from "../../src/index.js"; -import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { createMigratedConnection } from "../../src/testing.js"; describe("ui preferences data", () => { let db: DbConnection; diff --git a/packages/db/test/helpers/migrated-connection.ts b/packages/db/test/helpers/migrated-connection.ts deleted file mode 100644 index 60ffb426157..00000000000 --- a/packages/db/test/helpers/migrated-connection.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createConnection, migrate } from "../../src/index.js"; -import type { DbConnection } from "../../src/index.js"; -import type { CreateConnectionOptions } from "../../src/connection.js"; - -let migratedTemplate: Buffer | null = null; - -function getMigratedTemplate(): Buffer { - if (migratedTemplate === null) { - const db = createConnection(":memory:"); - try { - migrate(db); - migratedTemplate = db.$client.serialize(); - } finally { - db.$client.close(); - } - } - - return migratedTemplate; -} - -export function prepareMigratedConnectionTemplate(): void { - getMigratedTemplate(); -} - -export function createMigratedConnection( - options: CreateConnectionOptions = {}, -): DbConnection { - return createConnection(getMigratedTemplate(), options); -} diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 0c26748dd26..e14da898430 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -27,7 +27,7 @@ import { import { createMigratedConnection, prepareMigratedConnectionTemplate, -} from "./helpers/migrated-connection.js"; +} from "../src/testing.js"; type InsertMigrationParameters = [string, number]; type DeleteMigrationParameters = [number]; diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 932582423f8..b00255d8659 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -7,12 +7,11 @@ import { import { describe, expect, it } from "vitest"; import { threadScope, turnScope } from "@bb/domain"; import { - createConnection, type DbConnection, type SlowDbQueryLogger, type SlowDbQueryLogFields, } from "../src/connection.js"; -import { migrate } from "../src/migrate.js"; +import { createMigratedConnection } from "../src/testing.js"; import { noopNotifier } from "../src/notifier.js"; import { createPendingInteraction, @@ -135,11 +134,10 @@ class CapturingSlowQueryLogger implements SlowDbQueryLogger { function setup(): TestDb { const logger = new CapturingSlowQueryLogger(); - const db = createConnection(":memory:", { + const db = createMigratedConnection({ slowQueryLogger: logger, slowQueryThresholdMs: 0, }); - migrate(db); const host = upsertHost(db, noopNotifier, { name: "query-plan-host", }); diff --git a/packages/db/test/setup/migrated-db-template.ts b/packages/db/test/setup/migrated-db-template.ts new file mode 100644 index 00000000000..b7b8601c3aa --- /dev/null +++ b/packages/db/test/setup/migrated-db-template.ts @@ -0,0 +1,3 @@ +import { setupMigratedConnectionTemplate } from "../../src/testing.js"; + +export default setupMigratedConnectionTemplate; diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts index b7ffe64756a..73aa8a04a26 100644 --- a/packages/db/vitest.config.ts +++ b/packages/db/vitest.config.ts @@ -7,6 +7,7 @@ export default defineWorkspaceTestConfig({ test: { silent: "passed-only", testTimeout: 15_000, + globalSetup: ["test/setup/migrated-db-template.ts"], projects: sharedWorkerProjects({ pkgDir: __dirname, name: "@bb/db", diff --git a/packages/scripts/test/seed-perf-fixture.test.ts b/packages/scripts/test/seed-perf-fixture.test.ts index c71af4f2c66..ed19b721c9d 100644 --- a/packages/scripts/test/seed-perf-fixture.test.ts +++ b/packages/scripts/test/seed-perf-fixture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createConnection, migrate } from "@bb/db"; +import { createMigratedConnection } from "@bb/db/testing"; import { parseStoredThreadEvent } from "@bb/domain"; import { seedPerfFixture } from "../src/lib/seed-perf-fixture.js"; @@ -13,8 +13,7 @@ interface SeededEventRow { describe("seedPerfFixture", () => { it("seeds a consistent fixture into a migrated database", () => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const result = seedPerfFixture(db, { hostId: "host_seedtest01", @@ -91,8 +90,7 @@ describe("seedPerfFixture", () => { .get(); expect(ftsRow).toBeDefined(); - const secondDb = createConnection(":memory:"); - migrate(secondDb); + const secondDb = createMigratedConnection(); const secondResult = seedPerfFixture(secondDb, { hostId: "host_seedtest01", workspacesRootPath: "/tmp/seed-workspaces", From 7493ceac389fa996b75122d1a83d16f195b99ab3 Mon Sep 17 00:00:00 2001 From: qa Date: Thu, 8 Oct 2026 23:13:39 -0700 Subject: [PATCH 02/10] Share the small test helpers instead of copying them per file `@bb/test-helpers` gains `makeTempDir`/`makeTempDirSync`, which register their own cleanup with `onTestFinished`, and `initRepo`. 43 local `makeTempDir` copies and their `tempDirs` arrays and `afterEach` cleanups go, along with `dispatch-helpers`' `cleanupTempDirs` and 9 `initRepo` copies. The shared cleanup keeps the Windows retry budget the host-daemon command tests used. Local `deferred` helpers move to `createDeferredPromise`, and local `jsonResponse` helpers to the platform `Response.json`. apps/app gets one `LocationProbe`, shared `react-resizable-panels` modules (the real browser build and a layout stub), and one question-shortcut `system-queries` mock. The theme-preview server tests use the SDK fake host instead of hand-built `BbPluginApi` casts. Kept local: `server-local.test.ts` (temp dirs from `beforeAll`), forkable plugins (no `@bb/*` imports), the two `jsonResponse` helpers with different semantics, and the per-test `@/lib/sdk` mocks (57 distinct shapes in 61 files). Co-Authored-By: Claude Opus 5.5 --- .../commands/CommandPalette.test.tsx | 8 +- .../components/layout/AppBreadcrumbs.test.tsx | 11 +- .../plugin/PluginAppOverlays.test.tsx | 7 +- .../plugin/PluginNewThreadComposer.test.tsx | 10 - .../PluginPendingInteractionComposer.test.tsx | 26 +- .../components/plugin/PluginSettings.test.tsx | 23 +- .../plugin/PluginSidebarFooterItems.test.tsx | 21 +- .../management/AddPluginDialog.test.tsx | 19 +- .../management/BrowsePluginsTab.test.tsx | 83 ++---- .../management/OpenPluginGuideButton.test.tsx | 41 ++- .../management/PluginAuthorPage.test.tsx | 10 +- .../management/UpdatePluginDialog.test.tsx | 22 +- .../editor/prompt-thread-link-paste.test.ts | 19 +- .../SecondaryPanelLayout.test.tsx | 40 +-- .../secondaryPanelSizing.test.tsx | 15 +- .../usePanelResizeSnap.test.tsx | 13 +- .../useSecondaryPanelResize.test.tsx | 15 +- .../AiServicesSettingsSection.test.tsx | 19 +- .../settings/MachinesSettingsSection.test.tsx | 8 +- .../MarketplacesSettingsSection.test.tsx | 20 +- ...endingInteractionBanner.shortcuts.test.tsx | 26 +- .../user-questions/QuestionForm.test.tsx | 25 +- .../app/src/hooks/useAppThemePreview.test.tsx | 17 +- apps/app/src/lib/plugin-bound-sdk.test.ts | 38 ++- apps/app/src/test/location-probe.tsx | 8 + .../question-shortcuts-system-queries-mock.ts | 26 ++ .../test/react-resizable-panels-browser.ts | 17 ++ .../src/test/react-resizable-panels-stub.tsx | 33 ++ .../RootComposeSecondaryContent.test.tsx | 50 +--- .../views/ToolsView.plugin-detail.test.tsx | 114 +++---- .../SplitThreadArea.archive.test.tsx | 12 +- ...litThreadArea.environment-archive.test.tsx | 7 +- .../thread-detail/SplitThreadArea.test.tsx | 6 +- .../ThreadDetailSecondaryContent.test.tsx | 34 +-- apps/cli/package.json | 1 + .../command-output/plugin-updates.test.ts | 58 ++-- .../command-output/server-export.test.ts | 22 +- apps/desktop/package.json | 1 + apps/desktop/test/desktop-browser-cdp.test.ts | 21 +- apps/desktop/test/server-moved.test.ts | 26 +- apps/host-daemon/src/app.test.ts | 18 +- apps/host-daemon/src/auth-state.test.ts | 20 +- apps/host-daemon/src/command-dispatch.test.ts | 21 +- .../src/command-handlers/file-write.test.ts | 20 +- .../src/command-handlers/host-files.test.ts | 76 ++--- .../install-global-skills.test.ts | 27 +- .../workspace-path-list.test.ts | 15 +- apps/host-daemon/src/daemon.test.ts | 19 +- apps/host-daemon/src/identity.test.ts | 20 +- apps/host-daemon/src/injected-skills.test.ts | 19 +- apps/host-daemon/src/runtime-manager.test.ts | 32 +- .../host-daemon/src/runtime-shell-env.test.ts | 19 +- .../src/terminals/terminal-manager.test.ts | 19 +- .../src/thread-storage-root.test.ts | 17 +- .../test/command/dispatch-helpers.ts | 22 -- .../test/command/environment-dispatch.test.ts | 7 +- .../environment-hook-unverified-stop.test.ts | 11 +- .../test/command/environment-hook.test.ts | 11 +- .../command/host-branches-dispatch.test.ts | 12 +- .../command/project-clone-private-env.test.ts | 10 +- .../test/command/thread-dispatch.test.ts | 7 +- .../test/command/thread-stop-races.test.ts | 4 +- .../test/command/workspace-dispatch.test.ts | 7 +- apps/mobile/package.json | 1 + .../src/data/connect/enrollment.test.ts | 13 +- .../push-registration-controller.test.ts | 15 +- .../push-subscriptions-api.test.ts | 35 ++- apps/mobile/src/lib/sdk/mobile-fetch.test.ts | 35 +-- .../src/services/projects/attachments.test.ts | 19 +- apps/server/test/desktop-browsers.test.ts | 25 +- apps/server/test/machine-auth.test.ts | 17 +- .../test/routes/plugin-cli-response.test.ts | 17 +- apps/server/test/server-move/export.test.ts | 24 +- apps/server/test/server-move/routes.test.ts | 20 +- .../test/services/async-ttl-memo.test.ts | 29 +- .../plugin-catalog-service.test.ts | 74 +++-- .../third-party-marketplaces.test.ts | 15 +- .../plugins/plugin-install-jobs.test.ts | 17 +- .../plugins/plugin-tool-calls.test.ts | 21 +- .../test/skills/builtin-skills-copy.test.ts | 20 +- .../test/skills/injected-skills.test.ts | 20 +- .../test/skills/submit-a-plugin.test.ts | 21 +- packages/host-watcher/test/watch-path.test.ts | 13 +- .../host-watcher/test/watch-status.test.ts | 109 ++++--- .../test/workspace-root-ignores.test.ts | 23 +- .../host-workspace/test/git-fetch.test.ts | 42 +-- .../test/git-host-upstream.test.ts | 21 +- .../test/git-ref-mutation-lock.test.ts | 34 +-- .../host-workspace/test/provision.test.ts | 43 +-- .../test/workspace-diff.test.ts | 146 +++++---- .../host-workspace/test/workspace.test.ts | 88 ++---- packages/provider-bridge-acp/package.json | 1 + .../src/bridge/agent-connection.test.ts | 35 +-- .../src/bridge/cursor-mcp-approval.test.ts | 32 +- .../src/bridge/mcp-server-entry.test.ts | 19 +- packages/scripts/package.json | 1 + packages/scripts/test/pid-file.test.ts | 17 +- .../scripts/test/request-dev-restart.test.ts | 15 +- .../scripts/test/run-dev-supervisor.test.ts | 27 +- packages/scripts/test/run-dev.test.ts | 17 +- packages/scripts/test/run-host-daemon.test.ts | 15 +- packages/secret-storage/package.json | 1 + .../secret-storage/test/secret-file.test.ts | 22 +- .../test/write-secret-file.test.ts | 22 +- packages/server-archive/package.json | 1 + packages/server-archive/test/archive.test.ts | 22 +- .../test/import-journal.test.ts | 30 +- .../server-archive/test/import-lock.test.ts | 29 +- packages/server-archive/test/import.test.ts | 21 +- .../server-archive/test/inventory.test.ts | 22 +- packages/server-archive/test/markers.test.ts | 22 +- packages/test-helpers/package.json | 6 +- packages/test-helpers/src/git-repo.ts | 26 ++ packages/test-helpers/src/index.ts | 2 + packages/test-helpers/src/temp-dir.ts | 25 ++ plugins/theme-preview/server.test.ts | 281 ++++++++---------- pnpm-lock.yaml | 24 ++ 117 files changed, 1043 insertions(+), 2054 deletions(-) create mode 100644 apps/app/src/test/location-probe.tsx create mode 100644 apps/app/src/test/question-shortcuts-system-queries-mock.ts create mode 100644 apps/app/src/test/react-resizable-panels-browser.ts create mode 100644 apps/app/src/test/react-resizable-panels-stub.tsx create mode 100644 packages/test-helpers/src/git-repo.ts create mode 100644 packages/test-helpers/src/temp-dir.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 315659b7827..dbfe5151e3f 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -9,7 +9,7 @@ import { waitFor, within, } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { createStore, Provider } from "jotai"; import { paletteThreadLifecyclesAtom } from "@/lib/command-palette/palette-preferences"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; @@ -42,6 +42,7 @@ import { } from "@/lib/plugin-thread-row-status"; import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; import { collectPluginAppRegistrations } from "@get-bb/plugin-sdk/internal/plugin-app-collector"; +import { LocationProbe } from "@/test/location-probe"; beforeAll(async () => { await import("./ThreadSearchPaletteMode"); @@ -275,11 +276,6 @@ function Handler({ command }: { command: AppCommandId }) { return null; } -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - const threadUpdatedAt = Date.now(); function makeThread( diff --git a/apps/app/src/components/layout/AppBreadcrumbs.test.tsx b/apps/app/src/components/layout/AppBreadcrumbs.test.tsx index 7f4b37c25fb..514b313a7c7 100644 --- a/apps/app/src/components/layout/AppBreadcrumbs.test.tsx +++ b/apps/app/src/components/layout/AppBreadcrumbs.test.tsx @@ -1,18 +1,13 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it } from "vitest"; import { AppBreadcrumbs } from "./AppBreadcrumbs"; +import { LocationProbe } from "@/test/location-probe"; afterEach(cleanup); -function LocationProbe() { - return ( - {useLocation().pathname} - ); -} - describe("AppBreadcrumbs", () => { it("navigates through ancestors while keeping the resource passive", () => { render( @@ -45,7 +40,7 @@ describe("AppBreadcrumbs", () => { expect(screen.queryByRole("link", { name: "Weekly review" })).toBeNull(); fireEvent.click(screen.getByRole("link", { name: "Installed" })); - expect(screen.getByLabelText("Current location").textContent).toBe( + expect(screen.getByTestId("location").textContent).toBe( "/plugins/automations/automations", ); }); diff --git a/apps/app/src/components/plugin/PluginAppOverlays.test.tsx b/apps/app/src/components/plugin/PluginAppOverlays.test.tsx index 09415d0e766..e749a655414 100644 --- a/apps/app/src/components/plugin/PluginAppOverlays.test.tsx +++ b/apps/app/src/components/plugin/PluginAppOverlays.test.tsx @@ -9,7 +9,7 @@ import { screen, } from "@testing-library/react"; import { createPortal } from "react-dom"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; @@ -28,6 +28,7 @@ import { import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount"; import { PluginAppOverlays } from "./PluginAppOverlays"; import { makePluginRegistrationSet as registrationSet } from "@/test/fixtures/plugins"; +import { LocationProbe } from "@/test/location-probe"; vi.mock("@/components/dialogs/ThreadDeleteDialog", () => ({ ThreadDeleteDialog: () => null, @@ -73,10 +74,6 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ }), })); -function LocationProbe() { - return
{useLocation().pathname}
; -} - function PortaledProbe() { const context = useBbContext(); const navigate = useBbNavigate(); diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 7c2110adb96..b4e0dd073d3 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -21,7 +21,6 @@ import { createMemoryRouter, MemoryRouter, RouterProvider, - useLocation, } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { @@ -491,10 +490,6 @@ function ForkSeedSurface({ composer }: { composer: NewThreadComposerState }) { return composer.renderPromptBox({ mentionMenuPlacement: "bottom" }); } -function LocationProbe() { - return {useLocation().pathname}; -} - function composerElement( seed: NewThreadRequest, onSubmit: (request: NewThreadRequest) => void, @@ -502,7 +497,6 @@ function composerElement( ) { return ( - { ) { return render( - { mocks.extraProjects = [{ ...project, sources: [PROJECT.sources[0]] }]; rendered.rerender( - { mocks.serverAccessReady = true; rendered.rerender( - { mocks.serverAccessReady = false; rendered.rerender( - ({ - useSystemConfig: () => ({ - data: { - generalSettings: { ...defaultAppSettings }, - keybindings: [1, 2, 3].map((digit) => ({ - command: `question.select.${digit}`, - desktopOnly: false, - shortcut: { - key: String(digit), - mod: false, - meta: false, - control: false, - alt: false, - shift: false, - }, - when: { all: ["questionOpen"], none: [] }, - })), - }, - }), -})); +vi.mock( + "@/hooks/queries/system-queries", + () => import("@/test/question-shortcuts-system-queries-mock"), +); vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null })); const pane = vi.hoisted(() => ({ isFocused: true })); vi.mock("@/views/thread-detail/PaneContext", () => ({ diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 67fdecb7a65..97b8b1ed406 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -27,6 +27,7 @@ import { makePluginListItem, makePluginRegistrationSet, } from "@/test/fixtures/plugins"; +import { createDeferredPromise } from "@bb/test-helpers"; interface RecordedRequest { url: string; @@ -48,14 +49,6 @@ function jsonError(message: string): Response { }); } -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - const SETTINGS_VIEW = { ok: true, schema: { @@ -243,8 +236,8 @@ describe("PluginSettingsForm", () => { }); it("preserves text typed while an older save is pending", async () => { - const first = deferred(); - const second = deferred(); + const first = createDeferredPromise(); + const second = createDeferredPromise(); const requests: RecordedRequest[] = []; let saveCount = 0; vi.stubGlobal( @@ -296,8 +289,8 @@ describe("PluginSettingsForm", () => { }); it("preserves restored saved text while an older save is pending", async () => { - const first = deferred(); - const second = deferred(); + const first = createDeferredPromise(); + const second = createDeferredPromise(); const requests: RecordedRequest[] = []; let saveCount = 0; vi.stubGlobal( @@ -498,9 +491,9 @@ describe("PluginSettingsForm", () => { }); it("serializes immediate autosaves so the latest repeated toggle wins", async () => { - const first = deferred(); - const second = deferred(); - const third = deferred(); + const first = createDeferredPromise(); + const second = createDeferredPromise(); + const third = createDeferredPromise(); const requests: RecordedRequest[] = []; let saveCount = 0; vi.stubGlobal( diff --git a/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx b/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx index a231c115f34..37080bec0b2 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterItems.test.tsx @@ -20,7 +20,7 @@ import type { ExperimentalSidebarFooterActionContext, ExperimentalSidebarFooterDisclosureController, } from "@get-bb/plugin-sdk"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SidebarMenu, SidebarProvider } from "@/components/ui/sidebar.js"; @@ -43,6 +43,7 @@ import { collectPluginAppRegistrations, definePluginApp, } from "@/lib/plugin-app-definition"; +import { LocationProbe } from "@/test/location-probe"; function registrationSet( overrides: Partial, @@ -59,16 +60,6 @@ function registrationSet( }; } -function LocationProbe() { - const location = useLocation(); - return ( - - {location.pathname} - {location.hash} - - ); -} - function renderWithProviders(ui: ReactNode, store = createStore()) { return render( @@ -344,7 +335,7 @@ describe("PluginSidebarFooterItems", () => { renderWithProviders(); fireEvent.click(screen.getByRole("button", { name: "Remote settings" })); - expect(screen.getByLabelText("Current path").textContent).toBe( + expect(screen.getByTestId("location").textContent).toBe( "/settings/plugins/remote", ); }); @@ -372,7 +363,7 @@ describe("PluginSidebarFooterItems", () => { fireEvent.click(screen.getByRole("button", { name: "Remote access" })); expect(onActivate).toHaveBeenCalledOnce(); - expect(screen.getByLabelText("Current path").textContent).toBe( + expect(screen.getByTestId("location").textContent).toBe( "/settings/plugins/connect", ); }); @@ -559,7 +550,7 @@ describe("PluginSidebarFooterItems", () => { await screen.findByRole("menuitem", { name: "Customize footer" }), ); await waitFor(() => expect(onCustomize).toHaveBeenCalledTimes(1)); - expect(screen.getByLabelText("Current path").textContent).toBe("/"); + expect(screen.getByTestId("location").textContent).toBe("/"); fireEvent.click(screen.getByRole("button", { name: "Provider usage" })); expect(screen.getByText("Usage detail")).toBeDefined(); act(() => store.set(sidebarFooterCapacityAtom, 3)); @@ -570,7 +561,7 @@ describe("PluginSidebarFooterItems", () => { await screen.findByRole("menuitem", { name: "Customize footer" }), { button: 2, pointerType: "mouse" }, ); - expect(screen.getByLabelText("Current path").textContent).toBe("/"); + expect(screen.getByTestId("location").textContent).toBe("/"); fireEvent.click( await screen.findByRole("menuitem", { name: "Hide from footer" }), ); diff --git a/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx b/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx index 8e89aa3d424..4085643b687 100644 --- a/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx +++ b/apps/app/src/components/plugin/management/AddPluginDialog.test.tsx @@ -51,13 +51,6 @@ function installPlanFor(url: string): unknown { }; } -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - const STARTED_JOB: PluginInstallJob = { id: "job-1", target: { kind: "catalog", entryId: "linear", marketplace: "bb-official" }, @@ -86,12 +79,12 @@ function stubFetch( url === "/api/v1/plugins/install" || url === "/api/v1/plugin-catalog/install" ) { - return jsonResponse(installBody, installStatus); + return Response.json(installBody, { status: installStatus }); } if (url.startsWith("/api/v1/plugin-catalog/install-plan")) { - return jsonResponse({ plan: installPlanFor(url) }); + return Response.json({ plan: installPlanFor(url) }); } - return jsonResponse({ error: "not found" }, 404); + return Response.json({ error: "not found" }, { status: 404 }); }), ); return requests; @@ -118,10 +111,10 @@ describe("AddPluginDialog", () => { if (url.startsWith("/api/v1/plugin-catalog/install-plan")) { attempts += 1; return attempts === 1 - ? jsonResponse({ error: "Source unavailable" }, status) - : jsonResponse({ plan: installPlanFor(url) }); + ? Response.json({ error: "Source unavailable" }, { status }) + : Response.json({ plan: installPlanFor(url) }); } - return jsonResponse({ error: "Not found" }, 404); + return Response.json({ error: "Not found" }, { status: 404 }); }), ); renderDialog({ diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index 8072c2219d8..aeb533ffe34 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -9,7 +9,7 @@ import { waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { StrictMode } from "react"; import { appToast } from "@/components/ui/app-toast"; import { pluginCatalogSearchQueryKey } from "@/hooks/queries/query-keys"; @@ -17,6 +17,7 @@ import type { PluginCatalogSearchData, PluginCatalogSearchEntry, } from "@/hooks/queries/plugin-catalog-queries"; +import { LocationProbe } from "@/test/location-probe"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { makeInstalledPlugin } from "@/test/fixtures/plugins"; import { BrowsePluginsTab } from "./BrowsePluginsTab"; @@ -86,13 +87,6 @@ const TASKS_ENTRY: PluginCatalogSearchEntry = { installs: null, }; -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - type CatalogFixture = Omit & Partial>; @@ -102,22 +96,17 @@ function stubCatalog(data: CatalogFixture) { vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/v1/plugin-catalog/search")) { - return jsonResponse({ + return Response.json({ results: data.entries, collections: data.collections, categories: data.categories ?? [], }); } - return jsonResponse({ error: "not found" }, 404); + return Response.json({ error: "not found" }, { status: 404 }); }), ); } -function LocationProbe() { - const location = useLocation(); - return {location.search}; -} - function renderBrowse( data: CatalogFixture, initialEntry = "/plugins", @@ -191,8 +180,8 @@ describe("BrowsePluginsTab", () => { expect(screen.queryByRole("button", { name: "Plugin Guide" })).toBeNull(); fireEvent.click(create); expect(await screen.findByTestId("inline-composer")).toBeTruthy(); - expect(screen.getByTestId("location-search").textContent).toContain( - "query=Memory&view=create", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?query=Memory&view=create", ); }); @@ -262,11 +251,9 @@ describe("BrowsePluginsTab", () => { expect(search.value).toBe(value); } await waitFor(() => - expect( - new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", - ).get("query"), - ).toBe("Memo"), + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?query=Memo", + ), ); expect(search.value).toBe("Memo"); }); @@ -296,12 +283,9 @@ describe("BrowsePluginsTab", () => { ); fireEvent.click(await screen.findByRole("link", { name: "BB Official" })); - const params = new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=memory-and-context&sort=recently-added&author=11%3Abb-official%3Agithub%3Aget-bb", ); - expect(params.get("author")).toBe("11:bb-official:github:get-bb"); - expect(params.getAll("category")).toEqual(["memory-and-context"]); - expect(params.get("sort")).toBe("recently-added"); expect(onOpenPlugin).not.toHaveBeenCalled(); }); @@ -320,22 +304,13 @@ describe("BrowsePluginsTab", () => { await screen.findByRole("option", { name: /Tasks & Workflows/u }), ); - const params = new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=memory-and-context&category=security&category=tasks-and-workflows", ); - expect(params.getAll("category")).toEqual([ - "memory-and-context", - "security", - "tasks-and-workflows", - ]); fireEvent.click(screen.getByRole("option", { name: /Security/u })); - const nextParams = new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=memory-and-context&category=tasks-and-workflows", ); - expect(nextParams.getAll("category")).toEqual([ - "memory-and-context", - "tasks-and-workflows", - ]); fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); expect(screen.getByTestId("plugin-browse-shelves")).toBeTruthy(); }); @@ -465,11 +440,7 @@ describe("BrowsePluginsTab", () => { fireEvent.pointerDown(trigger); fireEvent.click(screen.getByRole("menuitem", { name: "Clear sort" })); expect(await screen.findByTestId("plugin-browse-shelves")).toBeTruthy(); - const params = new URLSearchParams( - screen.getByTestId("location-search").textContent ?? "", - ); - expect(params.has("sort")).toBe(false); - expect(params.has("direction")).toBe(false); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); }); it("opens a category shelf route and returns to Browse", async () => { @@ -487,8 +458,8 @@ describe("BrowsePluginsTab", () => { screen.getAllByRole("link", { name: "View all Memory & Context" })[0]!, ); expect(cardOrder()).toHaveLength(8); - expect(screen.getByTestId("location-search").textContent).toBe( - "?shelf=category%3Amemory-and-context", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?shelf=category%3Amemory-and-context", ); expect( screen.getByRole("heading", { name: "Memory & Context 8 plugins" }), @@ -505,7 +476,7 @@ describe("BrowsePluginsTab", () => { name: "Filter plugins by category: All categories", }), ).toBeTruthy(); - expect(screen.getByTestId("location-search").textContent).toBe(""); + expect(screen.getByTestId("location").textContent).toBe("/plugins"); }); it("loads a curated shelf directly and preserves its order", async () => { @@ -548,8 +519,8 @@ describe("BrowsePluginsTab", () => { await screen.findByRole("heading", { name: "Memory & Context 1 plugin" }); expect(cardOrder()).toEqual(["Open Memory details"]); fireEvent.click(screen.getByRole("link", { name: "Browse plugins" })); - expect(screen.getByTestId("location-search").textContent).toBe( - "?category=security", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=security", ); }); @@ -575,10 +546,10 @@ describe("BrowsePluginsTab", () => { if (String(input).startsWith("/api/v1/plugin-catalog/search")) { searchAttempts += 1; return searchAttempts === 1 - ? jsonResponse({ error: "unavailable" }, 503) - : jsonResponse({ results: [MEMORY_ENTRY], collections: [] }); + ? Response.json({ error: "unavailable" }, { status: 503 }) + : Response.json({ results: [MEMORY_ENTRY], collections: [] }); } - return jsonResponse({ error: "not found" }, 404); + return Response.json({ error: "not found" }, { status: 404 }); }), ); const { wrapper } = createQueryClientTestHarness(); @@ -613,13 +584,13 @@ describe("BrowsePluginsTab", () => { vi.fn(async (input: RequestInfo | URL) => { if (String(input).startsWith("/api/v1/plugin-catalog/search")) { return unavailable - ? jsonResponse({ error: "unavailable" }, 503) - : jsonResponse({ + ? Response.json({ error: "unavailable" }, { status: 503 }) + : Response.json({ results: [{ ...MEMORY_ENTRY, description }], collections: [], }); } - return jsonResponse({ error: "not found" }, 404); + return Response.json({ error: "not found" }, { status: 404 }); }), ); const { wrapper, queryClient } = createQueryClientTestHarness(); diff --git a/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx index 525740b1f81..f155d83b3fc 100644 --- a/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx +++ b/apps/app/src/components/plugin/management/OpenPluginGuideButton.test.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { InstalledPlugin } from "@bb/server-contract"; import { appToast } from "@/components/ui/app-toast"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; import { pluginInstallJobsQueryKey } from "@/hooks/queries/query-keys"; +import { LocationProbe } from "@/test/location-probe"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { OpenPluginGuideButton } from "./OpenPluginGuideButton"; @@ -68,17 +69,6 @@ const GUIDE_ENTRY: PluginCatalogSearchEntry = { incompatibleReason: null, }; -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function LocationProbe() { - return {useLocation().pathname}; -} - function renderGuide() { const { wrapper, queryClient } = createQueryClientTestHarness(); render( @@ -110,7 +100,7 @@ describe("OpenPluginGuideButton", () => { const fetchMock = vi.fn((input: RequestInfo | URL) => String(input).endsWith("/enable") ? enableResponse - : Promise.resolve(jsonResponse({ plugins: [GUIDE] })), + : Promise.resolve(Response.json({ plugins: [GUIDE] })), ); vi.stubGlobal("fetch", fetchMock); const { button } = renderGuide(); @@ -125,7 +115,7 @@ describe("OpenPluginGuideButton", () => { fireEvent.click(button); expect(fetchMock).toHaveBeenCalledTimes(2); - finishEnable(jsonResponse({ ok: true, plugin: enabled })); + finishEnable(Response.json({ ok: true, plugin: enabled })); await vi.waitFor(() => expect(screen.getByTestId("location").textContent).toBe( "/plugins/plugin-api-docs/plugin-api", @@ -135,7 +125,7 @@ describe("OpenPluginGuideButton", () => { it("opens an enabled Guide without enabling or reloading it", async () => { const fetchMock = vi.fn(async () => - jsonResponse({ + Response.json({ plugins: [{ ...GUIDE, enabled: true, status: "running" }], }), ); @@ -156,12 +146,12 @@ describe("OpenPluginGuideButton", () => { "fetch", vi.fn(async (input: RequestInfo | URL) => { if (!String(input).endsWith("/enable")) { - return jsonResponse({ plugins: [GUIDE] }); + return Response.json({ plugins: [GUIDE] }); } attempts += 1; return attempts === 1 - ? jsonResponse({ error: "Service unavailable" }, 503) - : jsonResponse({ + ? Response.json({ error: "Service unavailable" }, { status: 503 }) + : Response.json({ ok: true, plugin: { ...GUIDE, enabled: true, status: "running" }, }); @@ -188,8 +178,8 @@ describe("OpenPluginGuideButton", () => { it("offers the existing install dialog when Guide is absent and cancels without a write", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => String(input).startsWith("/api/v1/plugin-catalog/search") - ? jsonResponse({ results: [GUIDE_ENTRY], collections: [] }) - : jsonResponse({ plugins: [] }), + ? Response.json({ results: [GUIDE_ENTRY], collections: [] }) + : Response.json({ plugins: [] }), ); vi.stubGlobal("fetch", fetchMock); fireEvent.click(renderGuide().button); @@ -222,14 +212,17 @@ describe("OpenPluginGuideButton", () => { const url = String(input); if (url === "/api/v1/plugin-catalog/install") { return Promise.resolve( - jsonResponse({ ok: true, job: { ...job, state: "queued" } }, 202), + Response.json( + { ok: true, job: { ...job, state: "queued" } }, + { status: 202 }, + ), ); } if (url === "/api/v1/plugins/install-jobs") return finishedJobs; return Promise.resolve( url.startsWith("/api/v1/plugin-catalog/search") - ? jsonResponse({ results: [GUIDE_ENTRY], collections: [] }) - : jsonResponse({ plugins: [] }), + ? Response.json({ results: [GUIDE_ENTRY], collections: [] }) + : Response.json({ plugins: [] }), ); }); vi.stubGlobal("fetch", fetchMock); @@ -253,7 +246,7 @@ describe("OpenPluginGuideButton", () => { queryKey: pluginInstallJobsQueryKey(), }); finishInstall( - jsonResponse({ + Response.json({ jobs: [ { ...job, diff --git a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx index 965857a1ff8..7177fee19fd 100644 --- a/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx +++ b/apps/app/src/components/plugin/management/PluginAuthorPage.test.tsx @@ -7,11 +7,12 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { PluginAuthorPage } from "./PluginAuthorPage"; +import { LocationProbe } from "@/test/location-probe"; function catalogEntry( pluginId: string, @@ -82,13 +83,6 @@ const OTHER = catalogEntry("Other", { author: { name: "Pat Lee", github: null, url: null }, }); -function LocationProbe() { - const location = useLocation(); - return ( - {`${location.pathname}${location.search}`} - ); -} - function cardOrder(): string[] { return [ ...document.querySelectorAll( diff --git a/apps/app/src/components/plugin/management/UpdatePluginDialog.test.tsx b/apps/app/src/components/plugin/management/UpdatePluginDialog.test.tsx index 5f8c1ee01ad..08c96b48d75 100644 --- a/apps/app/src/components/plugin/management/UpdatePluginDialog.test.tsx +++ b/apps/app/src/components/plugin/management/UpdatePluginDialog.test.tsx @@ -33,13 +33,6 @@ function plugin(updateState: Partial): PluginListItem { }); } -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - const queryClients: QueryClient[] = []; function createDialogTestHarness() { @@ -129,7 +122,7 @@ describe("UpdatePluginDialog", () => { it("opens persisted failure details and retries an available update", async () => { const fetchMock = vi.fn(async () => - jsonResponse( + Response.json( { job: { id: "update-1", @@ -139,7 +132,7 @@ describe("UpdatePluginDialog", () => { phase: "preparing", }, }, - 202, + { status: 202 }, ), ); vi.stubGlobal("fetch", fetchMock); @@ -209,7 +202,9 @@ describe("UpdatePluginDialog", () => { state: "running", phase: "checking", }; - const fetchMock = vi.fn(async () => jsonResponse({ job }, 202)); + const fetchMock = vi.fn(async () => + Response.json({ job }, { status: 202 }), + ); vi.stubGlobal("fetch", fetchMock); const { wrapper, queryClient } = createDialogTestHarness(); const onOpenChange = vi.fn(); @@ -232,7 +227,10 @@ describe("UpdatePluginDialog", () => { vi.stubGlobal( "fetch", vi.fn(async () => - jsonResponse({ error: "plugin source is unavailable" }, 502), + Response.json( + { error: "plugin source is unavailable" }, + { status: 502 }, + ), ), ); const { wrapper } = createDialogTestHarness(); @@ -261,7 +259,7 @@ describe("UpdatePluginDialog", () => { it("treats a malformed 2xx update response as an error, never success", async () => { vi.stubGlobal( "fetch", - vi.fn(async () => jsonResponse({ status: "ok" })), + vi.fn(async () => Response.json({ status: "ok" })), ); const { wrapper } = createDialogTestHarness(); render( diff --git a/apps/app/src/components/promptbox/editor/prompt-thread-link-paste.test.ts b/apps/app/src/components/promptbox/editor/prompt-thread-link-paste.test.ts index 7f5820c5be7..0f8934f38c2 100644 --- a/apps/app/src/components/promptbox/editor/prompt-thread-link-paste.test.ts +++ b/apps/app/src/components/promptbox/editor/prompt-thread-link-paste.test.ts @@ -15,6 +15,7 @@ import { createPromptThreadLinkPasteExtension, promptThreadLinkPasteKey, } from "./prompt-thread-link-paste"; +import { createDeferredPromise } from "@bb/test-helpers"; const origin = "https://brsbl.getbb.app"; const projectId = "proj_khiw2za95v"; @@ -31,14 +32,6 @@ function resolution(id = threadId, project = projectId): Resolution { return { threadId: id, projectId: project, label: `Thread ${id}` }; } -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -} - function createEditor({ text = "", getCachedThread = () => null, @@ -95,7 +88,7 @@ describe("pasted thread link conversion", () => { ])( "preserves a pending URL after surrounding edits create %s", async (_name, prefix, suffix) => { - const pending = deferred(); + const pending = createDeferredPromise(); const editor = createEditor({ resolveThreads: () => pending.promise }); await paste(editor, url); editor.view.dispatch(editor.state.tr.insertText(suffix, 1 + url.length)); @@ -109,7 +102,7 @@ describe("pasted thread link conversion", () => { }, ); it("converts one paste atomically and replays exact URL and selection history", async () => { - const pending = deferred(); + const pending = createDeferredPromise(); const resolveThreads = vi.fn( (_ids: string[], _signal: AbortSignal) => pending.promise, ); @@ -168,7 +161,7 @@ describe("pasted thread link conversion", () => { }); it("maps boundary edits and the current caret while permanently excluding edited or removed occurrences", async () => { - const pending = deferred(); + const pending = createDeferredPromise(); const resolveThreads = vi.fn( (_ids: string[], _signal: AbortSignal) => pending.promise, ); @@ -204,7 +197,7 @@ describe("pasted thread link conversion", () => { it.each(["undo", "cancel", "destroy"])( "aborts pending lookup on %s and ignores its late result", async (action) => { - const pending = deferred(); + const pending = createDeferredPromise(); const resolveThreads = vi.fn( (_ids: string[], _signal: AbortSignal) => pending.promise, ); @@ -242,7 +235,7 @@ describe("pasted thread link conversion", () => { const low = GENERATED_ID_ALPHABET[index % GENERATED_ID_ALPHABET.length]; return `thr_22222222${high}${low}`; }); - const stalled = deferred(); + const stalled = createDeferredPromise(); const resolveThreads = vi.fn((batch: string[], _signal: AbortSignal) => batch.length === 32 ? Promise.resolve(batch.map((id) => resolution(id))) diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx index eb1dca7c6ba..0229d0a8fb8 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx @@ -15,13 +15,10 @@ import { SecondaryPanelLayout, type SecondaryPanelRenderArgs, } from "./SecondaryPanelLayout"; +import { panelGroupState } from "@/test/react-resizable-panels-stub"; type DrawerShellCallback = (open: boolean) => void; -const panelGroupState = vi.hoisted(() => ({ - getLayout: vi.fn(() => [60, 40]), - setLayout: vi.fn(), -})); const drawerShellState = vi.hoisted(() => ({ onContentAnimationEnd: undefined as DrawerShellCallback | undefined, })); @@ -35,37 +32,10 @@ vi.mock("jotai", async (importOriginal) => ({ useAtomValue: () => 40, })); -vi.mock("react-resizable-panels", async () => { - const React = await import("react"); - - const PanelGroup = React.forwardRef< - { - getLayout: () => number[]; - setLayout: (layout: number[]) => void; - }, - { children?: ReactNode } - >(({ children, ...props }, ref) => { - React.useImperativeHandle( - ref, - () => ({ - getLayout: panelGroupState.getLayout, - setLayout: panelGroupState.setLayout, - }), - [], - ); - return React.createElement( - "div", - { ...props, "data-testid": "panel-group" }, - children, - ); - }); - PanelGroup.displayName = "MockPanelGroup"; - - const Panel = ({ children }: { children?: ReactNode }) => - React.createElement("div", { "data-testid": "main-panel" }, children); - - return { Panel, PanelGroup }; -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-stub"), +); vi.mock("./CompactSecondaryPanelShelf", async () => { const React = await import("react"); diff --git a/apps/app/src/components/secondary-panel/secondaryPanelSizing.test.tsx b/apps/app/src/components/secondary-panel/secondaryPanelSizing.test.tsx index 099117dc69f..fe45b8af733 100644 --- a/apps/app/src/components/secondary-panel/secondaryPanelSizing.test.tsx +++ b/apps/app/src/components/secondary-panel/secondaryPanelSizing.test.tsx @@ -14,17 +14,10 @@ import { useSecondaryPanelMinimum, } from "./secondaryPanelSizing"; -vi.mock("react-resizable-panels", async () => { - const { createRequire } = await import("node:module"); - const { dirname, join } = await import("node:path"); - const require = createRequire(import.meta.url); - return require( - join( - dirname(require.resolve("react-resizable-panels/package.json")), - "dist/react-resizable-panels.browser.development.cjs.js", - ), - ); -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-browser"), +); function SecondaryPanelSizingProvider({ children }: { children: ReactNode }) { const { ref, minimum } = useSecondaryPanelSizing(); diff --git a/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx b/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx index 78dabc36b8a..27dcb88098d 100644 --- a/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx +++ b/apps/app/src/components/secondary-panel/usePanelResizeSnap.test.tsx @@ -19,15 +19,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { splitWidthLimits } from "@/lib/split-layout/sizing"; import { usePanelResizeSnap } from "./usePanelResizeSnap"; -vi.mock("react-resizable-panels", async () => { - const { createRequire } = await import("node:module"); - const { dirname, join } = await import("node:path"); - const require = createRequire(import.meta.url); - const root = dirname(require.resolve("react-resizable-panels/package.json")); - return require( - join(root, "dist/react-resizable-panels.browser.development.cjs.js"), - ); -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-browser"), +); const frames = new Map(); let nextFrameId = 0; diff --git a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.test.tsx b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.test.tsx index 3837fee66c0..672de3a071e 100644 --- a/apps/app/src/components/secondary-panel/useSecondaryPanelResize.test.tsx +++ b/apps/app/src/components/secondary-panel/useSecondaryPanelResize.test.tsx @@ -5,17 +5,10 @@ import { Panel, PanelGroup } from "react-resizable-panels"; import { afterEach, expect, it, vi } from "vitest"; import { useSecondaryPanelResize } from "./useSecondaryPanelResize"; -vi.mock("react-resizable-panels", async () => { - const { createRequire } = await import("node:module"); - const { dirname, join } = await import("node:path"); - const require = createRequire(import.meta.url); - return require( - join( - dirname(require.resolve("react-resizable-panels/package.json")), - "dist/react-resizable-panels.browser.development.cjs.js", - ), - ); -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-browser"), +); afterEach(cleanup); diff --git a/apps/app/src/components/settings/AiServicesSettingsSection.test.tsx b/apps/app/src/components/settings/AiServicesSettingsSection.test.tsx index db5c0bb8def..5a04778d582 100644 --- a/apps/app/src/components/settings/AiServicesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/AiServicesSettingsSection.test.tsx @@ -53,15 +53,8 @@ interface RecordedRequest { body: string; } -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - const TEST_SUCCESS = (): Response => - jsonResponse({ + Response.json({ ok: true, pluginId: "bb-ai", serviceId: "bb", @@ -86,7 +79,7 @@ function stubFetch( requests.push({ url, method: request.method, body: text }); if (url === "/api/v1/system/ai-services/selection") { const body = JSON.parse(text); - return jsonResponse({ + return Response.json({ ...VIEW, selections: { ...VIEW.selections, [body.task]: body.selection }, }); @@ -94,7 +87,7 @@ function stubFetch( if (url === "/api/v1/system/ai-services/test") { return testResponse(); } - return jsonResponse(VIEW); + return Response.json(VIEW); }), ); return requests; @@ -174,7 +167,7 @@ describe("AiServicesSettingsSection", () => { it("shows why a test failed when the service could not answer", async () => { stubFetch(() => - jsonResponse({ + Response.json({ ok: false, message: "Codex: Run `codex login` to sign in", durationMs: 3, @@ -195,9 +188,9 @@ describe("AiServicesSettingsSection", () => { it("shows the error when the test request itself fails", async () => { stubFetch(() => - jsonResponse( + Response.json( { code: "internal_error", message: "The sample prompt is empty" }, - 500, + { status: 500 }, ), ); const { wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index e664d39be9b..2f05bb5a8bb 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -16,7 +16,7 @@ import type { SystemConfigResponse, SystemMachineProvider, } from "@bb/server-contract"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { defaultExperiments, type Host } from "@bb/domain"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; @@ -25,6 +25,7 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { makeSystemConfig } from "@/test/fixtures/system-config"; import { MachinesSettingsSection } from "./MachinesSettingsSection"; import { focusWithKeyboard } from "@/test/keyboard-focus"; +import { LocationProbe } from "@/test/location-probe"; vi.mock("@/lib/sdk", () => ({ sdk: { @@ -138,11 +139,6 @@ function stubSidebarBootstrapFetch(): void { ); } -function LocationProbe() { - const location = useLocation(); - return
{location.pathname}
; -} - function renderSectionWithClient() { const { queryClient, wrapper } = createQueryClientTestHarness(); render( diff --git a/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx b/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx index ed5a7059137..4ff68cd40ba 100644 --- a/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx @@ -9,13 +9,6 @@ import { import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { MarketplacesSettingsSection } from "./MarketplacesSettingsSection"; -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - const OFFICIAL = { name: "bb-community", displayName: "BB Community", @@ -46,7 +39,7 @@ interface RecordedRequest { function stubFetch( marketplaces: unknown[], - addResponse = jsonResponse({ ok: true, marketplace: ACME }), + addResponse = Response.json({ ok: true, marketplace: ACME }), ): RecordedRequest[] { const requests: RecordedRequest[] = []; vi.stubGlobal( @@ -54,7 +47,7 @@ function stubFetch( vi.fn(async (url: string, init?: RequestInit) => { requests.push({ url, init }); if (url === "/api/v1/marketplaces" && init?.method !== "POST") { - return jsonResponse({ marketplaces }); + return Response.json({ marketplaces }); } if (url === "/api/v1/marketplaces") { return addResponse; @@ -63,9 +56,9 @@ function stubFetch( url.startsWith("/api/v1/marketplaces/") && init?.method === "DELETE" ) { - return jsonResponse({ ok: true, convertedPluginIds: ["notes"] }); + return Response.json({ ok: true, convertedPluginIds: ["notes"] }); } - return jsonResponse({ error: "not found" }, 404); + return Response.json({ error: "not found" }, { status: 404 }); }), ); return requests; @@ -180,7 +173,10 @@ describe("MarketplacesSettingsSection", () => { it("retains one alert when adding a marketplace fails", async () => { stubFetch( [OFFICIAL], - jsonResponse({ error: "marketplace directory does not exist" }, 400), + Response.json( + { error: "marketplace directory does not exist" }, + { status: 400 }, + ), ); const { wrapper } = createQueryClientTestHarness(); render(, { wrapper }); diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.shortcuts.test.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.shortcuts.test.tsx index 33b0c341168..7f615c61794 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.shortcuts.test.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.shortcuts.test.tsx @@ -10,30 +10,14 @@ import { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultAppSettings, type PendingInteraction } from "@bb/domain"; +import type { PendingInteraction } from "@bb/domain"; import { AppCommandProvider } from "@/components/commands/AppCommandProvider"; import { ThreadPendingInteractionBanners } from "./ThreadPendingInteractionBanner"; -vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemConfig: () => ({ - data: { - generalSettings: { ...defaultAppSettings }, - keybindings: [1, 2].map((digit) => ({ - command: `question.select.${digit}` as const, - desktopOnly: false, - shortcut: { - key: String(digit), - mod: false, - meta: false, - control: false, - alt: false, - shift: false, - }, - when: { all: ["questionOpen" as const], none: [] }, - })), - }, - }), -})); +vi.mock( + "@/hooks/queries/system-queries", + () => import("@/test/question-shortcuts-system-queries-mock"), +); vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null, diff --git a/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx index 106c865b183..bc9852a18b9 100644 --- a/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx +++ b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx @@ -14,32 +14,15 @@ import type { } from "@bb/shared-ui/question-form-state"; import { ThreadQuestionFormHost } from "./ThreadQuestionFormHost"; import { AppCommandProvider } from "@/components/commands/AppCommandProvider"; -import { defaultAppSettings } from "@bb/domain"; type InteractionPayload = { questions: Question[] }; type InteractionResponse = { answers: Record }; vi.mock("@/hooks/useVoiceInput", () => ({ useVoiceInput: vi.fn() })); -vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemConfig: () => ({ - data: { - generalSettings: { ...defaultAppSettings }, - keybindings: [1, 2, 3].map((digit) => ({ - command: `question.select.${digit}`, - desktopOnly: false, - shortcut: { - key: String(digit), - mod: false, - meta: false, - control: false, - alt: false, - shift: false, - }, - when: { all: ["questionOpen"], none: [] }, - })), - }, - }), -})); +vi.mock( + "@/hooks/queries/system-queries", + () => import("@/test/question-shortcuts-system-queries-mock"), +); vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null })); const pane = vi.hoisted(() => ({ isFocused: true })); vi.mock("@/views/thread-detail/PaneContext", () => ({ diff --git a/apps/app/src/hooks/useAppThemePreview.test.tsx b/apps/app/src/hooks/useAppThemePreview.test.tsx index 1d476bd8155..ea99fb7b2df 100644 --- a/apps/app/src/hooks/useAppThemePreview.test.tsx +++ b/apps/app/src/hooks/useAppThemePreview.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultResolvedCodeTheme, type AppTheme } from "@bb/domain"; import { applyAppThemeCss, clearAppThemePreview } from "@/lib/themes"; import { useAppThemePreview } from "./useAppThemePreview"; +import { createDeferredPromise } from "@bb/test-helpers"; const resolveMock = vi.hoisted(() => vi.fn()); @@ -24,16 +25,6 @@ function customTheme(themeId: string, customCss: string): AppTheme { }; } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function styleText(): string | null { return document.getElementById("bb-app-theme")?.textContent ?? null; } @@ -80,7 +71,7 @@ describe("useAppThemePreview", () => { it("ignores a resolution that lands after the pointer left", async () => { applyAppThemeCss(COMMITTED); - const slow = deferred(); + const slow = createDeferredPromise(); resolveMock.mockReturnValue(slow.promise); const { result } = renderPreviewHook(); @@ -94,8 +85,8 @@ describe("useAppThemePreview", () => { it("keeps the latest hovered theme when an earlier fetch resolves late", async () => { applyAppThemeCss(COMMITTED); - const first = deferred(); - const second = deferred(); + const first = createDeferredPromise(); + const second = createDeferredPromise(); resolveMock .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise); diff --git a/apps/app/src/lib/plugin-bound-sdk.test.ts b/apps/app/src/lib/plugin-bound-sdk.test.ts index d85a6344dd6..03937fcf503 100644 --- a/apps/app/src/lib/plugin-bound-sdk.test.ts +++ b/apps/app/src/lib/plugin-bound-sdk.test.ts @@ -23,16 +23,7 @@ import { import type { SidebarBootstrapResponse } from "@bb/server-contract"; import { makeThreadResponse } from "@/test/fixtures/thread-responses"; import { bindSdkToPlugin, getPluginBoundSdk } from "./plugin-bound-sdk"; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, reject, resolve }; -} +import { createDeferredPromise } from "@bb/test-helpers"; function makeSdk() { const threads = { @@ -207,10 +198,13 @@ describe("bindSdkToPlugin", () => { const { sdk, queryClient, threads } = makeSdk(); const pending = new Map< string, - ReturnType>> + ReturnType< + typeof createDeferredPromise> + > >(); threads.update.mockImplementation(({ threadId }: { threadId: string }) => { - const request = deferred>(); + const request = + createDeferredPromise>(); pending.set(threadId, request); return request.promise; }); @@ -284,7 +278,8 @@ describe("bindSdkToPlugin", () => { it("keeps an in-flight move over refetched sidebar data until the write settles", async () => { const { sdk, queryClient, threads } = makeSdk(); - const request = deferred>(); + const request = + createDeferredPromise>(); threads.update.mockImplementation(() => request.promise); const sidebarKey = sidebarNavigationQueryKey(); const serverNavigation = (sectionId: string) => @@ -391,7 +386,7 @@ describe("bindSdkToPlugin", () => { ], }), ); - const pending = deferred<{ ok: true }>(); + const pending = createDeferredPromise<{ ok: true }>(); threads.unarchive.mockReturnValueOnce(pending.promise); const bound = bindSdkToPlugin(sdk, "thread-list", queryClient); const restore = bound.threads.unarchive({ threadId: entry.id }); @@ -437,7 +432,8 @@ describe("bindSdkToPlugin", () => { projects: [makeProjectWithThreadsResponse({ threads: [entry] })], }), ); - const pending = deferred>(); + const pending = + createDeferredPromise>(); threads.unpin.mockReturnValueOnce(pending.promise); threads.update.mockResolvedValueOnce( makeThreadResponse({ id: entry.id, pinnedAt: null, sectionId: "new" }), @@ -500,7 +496,7 @@ describe("bindSdkToPlugin", () => { queryClient.setQueryData(projectsQueryKey(), [project]); queryClient.setQueryData(hostsQueryKey(), [host]); queryClient.setQueryData(hostQueryKey(host.id), host); - const pending = deferred(); + const pending = createDeferredPromise(); const bound = bindSdkToPlugin(sdk, "thread-list", queryClient); const current = () => kind === "host" @@ -572,7 +568,7 @@ describe("bindSdkToPlugin", () => { sections: [section], }), ); - const pending = deferred(); + const pending = createDeferredPromise(); const bound = bindSdkToPlugin(sdk, "thread-list", queryClient); let removal; if (kind === "project") { @@ -626,7 +622,9 @@ describe("bindSdkToPlugin", () => { async (outcome) => { const { sdk, environments, queryClient } = makeSdk(); const pending = - deferred>>(); + createDeferredPromise< + Awaited> + >(); environments.archiveThreads.mockReturnValueOnce(pending.promise); const entries = [ makeThreadListEntry({ id: "thr_1", environmentId: "env_1" }), @@ -741,7 +739,7 @@ describe("bindSdkToPlugin", () => { { label: "clear", name: null }, ])("optimistically applies a plugin environment $label", async ({ name }) => { const { sdk, environments, queryClient } = makeSdk(); - const pending = deferred>(); + const pending = createDeferredPromise>(); environments.update.mockReturnValueOnce(pending.promise); queryClient.setQueryData( environmentQueryKey("env_1"), @@ -772,7 +770,7 @@ describe("bindSdkToPlugin", () => { it("rolls back a failed plugin environment rename", async () => { const { sdk, environments, queryClient } = makeSdk(); - const pending = deferred>(); + const pending = createDeferredPromise>(); environments.update.mockReturnValueOnce(pending.promise); queryClient.setQueryData( environmentQueryKey("env_1"), diff --git a/apps/app/src/test/location-probe.tsx b/apps/app/src/test/location-probe.tsx new file mode 100644 index 00000000000..4a390f2599e --- /dev/null +++ b/apps/app/src/test/location-probe.tsx @@ -0,0 +1,8 @@ +import { useLocation } from "react-router-dom"; + +export function LocationProbe() { + const { pathname, search, hash } = useLocation(); + return ( + {`${pathname}${search}${hash}`} + ); +} diff --git a/apps/app/src/test/question-shortcuts-system-queries-mock.ts b/apps/app/src/test/question-shortcuts-system-queries-mock.ts new file mode 100644 index 00000000000..130f4a0b8cc --- /dev/null +++ b/apps/app/src/test/question-shortcuts-system-queries-mock.ts @@ -0,0 +1,26 @@ +import { defaultAppSettings, type AppKeybindings } from "@bb/domain"; + +const questionSelectKeybindings: AppKeybindings = ([1, 2, 3] as const).map( + (digit) => ({ + command: `question.select.${digit}`, + desktopOnly: false, + shortcut: { + key: String(digit), + mod: false, + meta: false, + control: false, + alt: false, + shift: false, + }, + when: { all: ["questionOpen"], none: [] }, + }), +); + +export function useSystemConfig() { + return { + data: { + generalSettings: { ...defaultAppSettings }, + keybindings: questionSelectKeybindings, + }, + }; +} diff --git a/apps/app/src/test/react-resizable-panels-browser.ts b/apps/app/src/test/react-resizable-panels-browser.ts new file mode 100644 index 00000000000..a1eb6e9612d --- /dev/null +++ b/apps/app/src/test/react-resizable-panels-browser.ts @@ -0,0 +1,17 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); +const browserBuild: typeof import("react-resizable-panels") = require( + join( + dirname(require.resolve("react-resizable-panels/package.json")), + "dist/react-resizable-panels.browser.development.cjs.js", + ), +); + +export const { + Panel, + PanelGroup, + PanelResizeHandle, + disableGlobalCursorStyles, +} = browserBuild; diff --git a/apps/app/src/test/react-resizable-panels-stub.tsx b/apps/app/src/test/react-resizable-panels-stub.tsx new file mode 100644 index 00000000000..9a64956fe55 --- /dev/null +++ b/apps/app/src/test/react-resizable-panels-stub.tsx @@ -0,0 +1,33 @@ +import { + forwardRef, + useImperativeHandle, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { vi } from "vitest"; + +export const panelGroupState = { + getLayout: vi.fn((): number[] => [60, 40]), + setLayout: vi.fn((_layout: number[]) => {}), +}; + +export const PanelGroup = forwardRef< + typeof panelGroupState, + HTMLAttributes +>(({ children, ...props }, ref) => { + useImperativeHandle(ref, () => panelGroupState, []); + return ( +
+ {children} +
+ ); +}); +PanelGroup.displayName = "MockPanelGroup"; + +export function Panel({ children }: { children?: ReactNode }) { + return
{children}
; +} + +export function PanelResizeHandle({ children }: { children?: ReactNode }) { + return
{children}
; +} diff --git a/apps/app/src/views/RootComposeSecondaryContent.test.tsx b/apps/app/src/views/RootComposeSecondaryContent.test.tsx index 605347c351e..6c853c7068c 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.test.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.test.tsx @@ -12,24 +12,12 @@ import { ROOT_COMPOSE_PINNED_PANEL_TOGGLE_POSITION_CLASS, RootComposeSecondaryContent, } from "./RootComposeSecondaryContent"; +import { panelGroupState } from "@/test/react-resizable-panels-stub"; type RootComposeSecondaryContentProps = ComponentProps< typeof RootComposeSecondaryContent >; -interface PanelGroupHandle { - getLayout: () => number[]; - setLayout: (layout: number[]) => void; -} - -interface PanelGroupProps { - children?: ReactNode; -} - -interface PanelProps { - children?: ReactNode; -} - interface RenderRootComposeArgs { isCompactViewport: boolean; isSecondaryPanelOpen: boolean; @@ -40,11 +28,6 @@ type TestDesktopWindow = { bbDesktop?: { platform: "macos" }; }; -const panelGroupState = vi.hoisted(() => ({ - getLayout: vi.fn(() => [60, 40]), - setLayout: vi.fn(), -})); - const noop = () => {}; function setMacosDesktopChrome(): void { @@ -60,33 +43,10 @@ vi.mock("jotai", async (importOriginal) => ({ useAtomValue: () => 40, })); -vi.mock("react-resizable-panels", async () => { - const React = await import("react"); - - const PanelGroup = React.forwardRef( - ({ children }, ref) => { - React.useImperativeHandle( - ref, - () => ({ - getLayout: panelGroupState.getLayout, - setLayout: panelGroupState.setLayout, - }), - [], - ); - return React.createElement( - "div", - { "data-testid": "panel-group" }, - children, - ); - }, - ); - PanelGroup.displayName = "MockPanelGroup"; - - const Panel = ({ children }: PanelProps) => - React.createElement("div", { "data-testid": "panel" }, children); - - return { Panel, PanelGroup }; -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-stub"), +); vi.mock("@bb/shared-ui/responsive-overlay", async (importOriginal) => { const React = await import("react"); diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index ccdfcf5fd6e..df4c7a77f6a 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -51,22 +51,16 @@ import { makePluginListItem, makePluginRegistrationSet, } from "@/test/fixtures/plugins"; +import { LocationProbe } from "@/test/location-probe"; vi.mock("@/components/layout/AppLayout", () => ({ AppLayout: ({ children }: { children: ReactNode }) => <>{children}, })); -vi.mock("react-resizable-panels", async () => { - const { createRequire } = await import("node:module"); - const { dirname, join } = await import("node:path"); - const require = createRequire(import.meta.url); - return require( - join( - dirname(require.resolve("react-resizable-panels/package.json")), - "dist/react-resizable-panels.browser.cjs.js", - ), - ); -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-browser"), +); const GITHUB_PLUGIN = makePluginListItem({ id: "github", @@ -138,20 +132,6 @@ function RoutedPluginsView() { ); } -function LocationProbe() { - const location = useLocation(); - return ( - <> - {location.pathname} - {location.search} - - ); -} - -function routeUrl(): string { - return `${screen.getByTestId("route-path").textContent}${screen.getByTestId("route-search").textContent}`; -} - function HistoryBackButton() { const navigate = useNavigate(); return ; @@ -913,7 +893,7 @@ describe("BB Official plugin detail routing", () => { expect(screen.getByRole("button", { name: "Close GitHub" })).toBeTruthy(); fireEvent.click(screen.getByRole("link", { name: "Browse plugins" })); await waitFor(() => - expect(screen.getByTestId("route-search").textContent).toBe(""), + expect(screen.getByTestId("location").textContent).toBe("/plugins"), ); expect( screen.getByRole("button", { name: "Close Automations" }), @@ -996,7 +976,9 @@ describe("BB Official plugin detail routing", () => { ); await waitFor(() => { - expect(routeUrl()).toBe("/plugins/canvas?q=canvas&listing=bb-community"); + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/canvas?q=canvas&listing=bb-community", + ); }); await waitFor(() => { expect(document.querySelector("[data-plugin-summary]")?.textContent).toBe( @@ -1014,7 +996,9 @@ describe("BB Official plugin detail routing", () => { screen.getByRole("button", { name: "View installed plugin" }), ); await waitFor(() => { - expect(routeUrl()).toBe("/plugins/canvas?q=canvas"); + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/canvas?q=canvas", + ); }); await waitFor(() => { expect(document.querySelector("[data-plugin-summary]")?.textContent).toBe( @@ -1025,7 +1009,9 @@ describe("BB Official plugin detail routing", () => { fireEvent.click(screen.getByRole("button", { name: "View listing" })); await waitFor(() => { - expect(routeUrl()).toBe("/plugins/canvas?q=canvas&listing=bb-community"); + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/canvas?q=canvas&listing=bb-community", + ); }); }); @@ -1349,9 +1335,8 @@ describe("BB Official plugin detail routing", () => { screen.getByRole("button", { name: `Close ${pluginName}` }), ); await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); - expect(screen.getByTestId("route-search").textContent).toBe( - installed ? "?view=installed&query=Local+GitHub" : "", + expect(screen.getByTestId("location").textContent).toBe( + installed ? "/plugins?view=installed&query=Local+GitHub" : "/plugins", ); expect(document.activeElement).toBe(card); if (installed) expect(search).toHaveProperty("value", "Local GitHub"); @@ -1365,8 +1350,8 @@ describe("BB Official plugin detail routing", () => { fireEvent.click(screen.getByRole("button", { name: "Browser back" })); await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe( - "/plugins/github", + expect(screen.getByTestId("location").textContent).toBe( + installed ? "/plugins/github?view=installed" : "/plugins/github", ); }); }, @@ -1447,9 +1432,7 @@ describe("BB Official plugin detail routing", () => { name: "Search installed plugins", }), ).toBeTruthy(); - expect(screen.getByTestId("route-path").textContent).toBe( - path.split("?")[0], - ); + expect(screen.getByTestId("location").textContent).toBe(path); const pluginButton = await screen.findByRole("button", { name: "GitHub plugin details", }); @@ -1463,13 +1446,10 @@ describe("BB Official plugin detail routing", () => { expect(screen.getAllByText("GitHub", { selector: "h1" })).toHaveLength(1); fireEvent.click(relatedPluginButton); await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe( - "/plugins/automations", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/automations?view=installed", ); }); - expect(screen.getByTestId("route-search").textContent).toBe( - "?view=installed", - ); expect( await screen.findByRole("button", { name: "Close Automations" }), ).toBeTruthy(); @@ -1539,23 +1519,17 @@ describe("BB Official plugin detail routing", () => { (await screen.findAllByRole("link", { name: "BB Official" }))[0]!, ); expect(await screen.findByRole("heading", { name: /^BB/u })).toBeTruthy(); - let params = new URLSearchParams( - screen.getByTestId("route-search").textContent ?? "", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=code-and-reviews&sort=recently-added&author=11%3Abb-official%3Agithub%3Aget-bb", ); - expect(params.get("author")).toBe("11:bb-official:github:get-bb"); - expect(params.getAll("category")).toEqual(["code-and-reviews"]); - expect(params.get("sort")).toBe("recently-added"); fireEvent.click(screen.getByRole("button", { name: "Browser back" })); await waitFor(() => { expect(screen.queryByRole("heading", { name: /^BB/u })).toBeNull(); }); - params = new URLSearchParams( - screen.getByTestId("route-search").textContent ?? "", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?category=code-and-reviews&sort=recently-added", ); - expect(params.has("author")).toBe(false); - expect(params.getAll("category")).toEqual(["code-and-reviews"]); - expect(params.get("sort")).toBe("recently-added"); fireEvent.click( (await screen.findAllByRole("link", { name: "BB Official" }))[0]!, @@ -1626,14 +1600,11 @@ describe("BB Official plugin detail routing", () => { const authorLinks = screen.getAllByRole("link", { name: "BB Official" }); fireEvent.click(authorLinks.at(-1)!); await waitFor(() => { - expect(screen.getByTestId("route-path").textContent).toBe("/plugins"); + expect(screen.getByTestId("location").textContent).toBe( + "/plugins?author=11%3Abb-official%3Agithub%3Aget-bb", + ); }); expect(await screen.findByRole("heading", { name: /^BB/u })).toBeTruthy(); - expect( - new URLSearchParams( - screen.getByTestId("route-search").textContent ?? "", - ).get("author"), - ).toBe("11:bb-official:github:get-bb"); }); }); @@ -2447,7 +2418,7 @@ describe("detail disable workspace cleanup", () => { if (fails) { expect(store.get(splitLayoutAtom)).toEqual(layout); expect(store.get(maximizedPaneIdAtom)).toBe("github"); - expect(screen.getByTestId("route-path").textContent).toBe( + expect(screen.getByTestId("location").textContent).toBe( "/plugins/github/main", ); } else { @@ -2455,9 +2426,9 @@ describe("detail disable workspace cleanup", () => { content: { kind: "new-thread" }, }); expect(store.get(maximizedPaneIdAtom)).toBeNull(); - expect(screen.getByTestId("route-path").textContent).toBe("/"); + expect(screen.getByTestId("location").textContent).toBe("/"); fireEvent.click(screen.getByRole("button", { name: "Browser back" })); - expect(screen.getByTestId("route-path").textContent).toBe("/skills"); + expect(screen.getByTestId("location").textContent).toBe("/skills"); } }, ); @@ -2549,16 +2520,16 @@ describe("plugin detail source and settings", () => { "value", "get-bb/bb", ); - expect(screen.getByTestId("route-search").textContent).toBe( - "?view=installed&configure=github", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/github?view=installed&configure=github", ); fireEvent.click(screen.getByRole("button", { name: "Plugin details" })); expect( await screen.findByRole("heading", { name: "Details" }), ).toBeTruthy(); - expect(screen.getByTestId("route-search").textContent).toBe( - "?view=installed", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/github?view=installed", ); fireEvent.click(screen.getByRole("button", { name: "Browser back" })); @@ -2640,8 +2611,8 @@ describe("plugin detail source and settings", () => { "value", "get-bb/bb", ); - expect(screen.getByTestId("route-search").textContent).toBe( - "?view=installed&configure=github", + expect(screen.getByTestId("location").textContent).toBe( + "/plugins/github?view=installed&configure=github", ); }); @@ -2666,13 +2637,16 @@ describe("plugin detail source and settings", () => { "value", "get-bb/bb", ); - expect(screen.getByTestId("route-path").textContent).toBe("/threads/thr_1"); - expect(screen.getByTestId("route-search").textContent).toBe("?panel=files"); + expect(screen.getByTestId("location").textContent).toBe( + "/threads/thr_1?panel=files", + ); fireEvent.click(screen.getByRole("button", { name: "Plugin details" })); expect( await screen.findByRole("heading", { name: "Details" }), ).toBeTruthy(); - expect(screen.getByTestId("route-search").textContent).toBe("?panel=files"); + expect(screen.getByTestId("location").textContent).toBe( + "/threads/thr_1?panel=files", + ); }); }); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx index ead6e941674..c2528f046e5 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx @@ -10,7 +10,7 @@ import { } from "@testing-library/react"; import { createStore, Provider as JotaiProvider } from "jotai"; import { useContext } from "react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider, @@ -27,6 +27,7 @@ import type { SplitLayout } from "@/lib/split-layout"; import { PaneContext } from "./PaneContext"; import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; import { SplitThreadArea } from "./SplitThreadArea"; +import { LocationProbe } from "@/test/location-probe"; const ARCHIVED_AT = 1_700_000_000_000; @@ -121,11 +122,6 @@ function ArchiveHarness({ threadId }: { threadId: string }) { ); } -function LocationProbe() { - const location = useLocation(); - return
{location.pathname}
; -} - function UnarchiveHarness() { const mutation = useUnarchiveThread(); return ( @@ -208,7 +204,9 @@ describe("SplitThreadArea archive pruning", () => { fireEvent.click(screen.getByTestId("unarchive")); await waitFor(() => expect(archivedAtOf(queryClient, "thr-b")).toBeNull()); - await act(async () => pendingArchive!.reject(new Error("unarchive failed"))); + await act(async () => + pendingArchive!.reject(new Error("unarchive failed")), + ); await waitFor(() => expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT), diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.environment-archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.environment-archive.test.tsx index 31899fb147f..9adbdebb20b 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.environment-archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.environment-archive.test.tsx @@ -9,7 +9,8 @@ import { } from "@testing-library/react"; import { createStore, Provider as JotaiProvider } from "jotai"; import { useContext } from "react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; +import { LocationProbe } from "@/test/location-probe"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { @@ -115,10 +116,6 @@ const LAYOUT: SplitLayout = { focusedPaneId: "pane-env", }; -function LocationProbe() { - return
{useLocation().pathname}
; -} - function ArchiveEnvironmentButton() { const { archiveEnvironmentThreads } = useThreadActions(); return ( diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 40869732164..66b086a1d06 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -52,6 +52,7 @@ import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; import { SplitThreadArea } from "./SplitThreadArea"; import { applyThreadOpenToLayout } from "./splitThreadNavigation"; import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; +import { LocationProbe } from "@/test/location-probe"; const threadStore = vi.hoisted( () => @@ -643,11 +644,6 @@ function registerDocsPanel() { ); } -function LocationProbe() { - const location = useLocation(); - return
{location.pathname}
; -} - function ExternalNav({ to }: { to: string }) { const navigate = useNavigate(); return ( diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx index f4dfbc63c6c..d9c4db7b29e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx @@ -75,36 +75,10 @@ vi.mock("jotai", async (importOriginal) => { }; }); -vi.mock("react-resizable-panels", async () => { - const React = await import("react"); - - const PanelGroup = React.forwardRef< - { - getLayout: () => number[]; - setLayout: (layout: number[]) => void; - }, - { children?: ReactNode } - >(({ children }, ref) => { - React.useImperativeHandle( - ref, - () => ({ getLayout: () => [50, 50], setLayout: () => {} }), - [], - ); - return React.createElement( - "div", - { "data-testid": "panel-group" }, - children, - ); - }); - PanelGroup.displayName = "MockPanelGroup"; - - const Panel = ({ children }: { children?: ReactNode }) => - React.createElement("div", { "data-testid": "panel" }, children); - const PanelResizeHandle = ({ children }: { children?: ReactNode }) => - React.createElement("div", null, children); - - return { Panel, PanelGroup, PanelResizeHandle }; -}); +vi.mock( + "react-resizable-panels", + () => import("@/test/react-resizable-panels-stub"), +); vi.mock( "@/components/secondary-panel/ThreadMetadataContent", diff --git a/apps/cli/package.json b/apps/cli/package.json index e26b205ef82..e0068a782f4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,6 +33,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@tailwindcss/node": "^4.3.0", "@tailwindcss/oxide": "^4.3.0", diff --git a/apps/cli/src/__tests__/command-output/plugin-updates.test.ts b/apps/cli/src/__tests__/command-output/plugin-updates.test.ts index b1f9d0edb53..c804f9b7b31 100644 --- a/apps/cli/src/__tests__/command-output/plugin-updates.test.ts +++ b/apps/cli/src/__tests__/command-output/plugin-updates.test.ts @@ -41,13 +41,6 @@ const pluginList = (id: string, source: string) => ({ ], }); -function jsonResponse(value: object, status = 200): Response { - return new Response(JSON.stringify(value), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - describe("bb plugin update commands", () => { setupCommandOutputTestEnvironment(); @@ -56,7 +49,7 @@ describe("bb plugin update commands", () => { it("outdated renders every outcome, reasons, and the dev-build annotation", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ results: [ { id: "a", outcome: "current", installed: version("1.0.0") }, { @@ -108,7 +101,7 @@ describe("bb plugin update commands", () => { installed: version("1.0.0"), }, ]; - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ results })); + vi.mocked(fetch).mockResolvedValueOnce(Response.json({ results })); await runCommand(["plugin", "outdated", "--json"], register); @@ -119,7 +112,7 @@ describe("bb plugin update commands", () => { it("source renders resolved source and activation history", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ requested: "npm:notes@^1", resolved: "1.2.0", integrity: "sha512-test", @@ -150,7 +143,7 @@ describe("bb plugin update commands", () => { installedAt: 1_752_300_000_000, history: [{ version: "1.0.0", activatedAt: 1_752_300_000_000 }], }; - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(source)); + vi.mocked(fetch).mockResolvedValueOnce(Response.json(source)); await runCommand(["plugin", "source", "notes", "--json"], register); @@ -161,7 +154,7 @@ describe("bb plugin update commands", () => { it("reload rejects an unknown id instead of reporting the full inventory", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ ok: true, plugins: pluginList("other", "path:/other").plugins, }), @@ -178,7 +171,7 @@ describe("bb plugin update commands", () => { it("reload prints only the requested plugin in human output", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ ok: true, plugins: [ ...pluginList("notes", "path:/notes").plugins, @@ -196,7 +189,7 @@ describe("bb plugin update commands", () => { it("list annotates only CLI commands shadowed by core", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ plugins: [ { ...pluginList("shadower", "path:/shadower").plugins[0], @@ -222,7 +215,7 @@ describe("bb plugin update commands", () => { it("skips pinned plugins with manual reinstall guidance", async () => { vi.mocked(fetch).mockResolvedValueOnce( - jsonResponse({ + Response.json({ results: [ { id: "notes", @@ -252,7 +245,7 @@ describe("bb plugin update commands", () => { }; vi.mocked(fetch) .mockResolvedValueOnce( - jsonResponse({ + Response.json({ results: [ { id: "notes", @@ -263,8 +256,8 @@ describe("bb plugin update commands", () => { ], }), ) - .mockResolvedValueOnce(jsonResponse(pluginList("notes", "npm:notes@^1"))) - .mockResolvedValueOnce(jsonResponse({ job }, 202)); + .mockResolvedValueOnce(Response.json(pluginList("notes", "npm:notes@^1"))) + .mockResolvedValueOnce(Response.json({ job }, { status: 202 })); await runCommand( ["plugin", "update", "notes", "--yes", "--no-wait"], register, @@ -274,7 +267,7 @@ describe("bb plugin update commands", () => { "bb plugin update-jobs update-1", ); vi.mocked(console.log).mockClear(); - vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ job })); + vi.mocked(fetch).mockResolvedValueOnce(Response.json({ job })); await runCommand(["plugin", "update-jobs", "update-1", "--json"], register); expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ JSON.stringify({ jobs: [job] }, null, 2), @@ -285,7 +278,7 @@ describe("bb plugin update commands", () => { const fetchMock = vi.mocked(fetch); fetchMock .mockResolvedValueOnce( - jsonResponse({ + Response.json({ results: [ { id: "pin", outcome: "pinned", installed: version("1") }, { @@ -303,9 +296,9 @@ describe("bb plugin update commands", () => { ], }), ) - .mockResolvedValueOnce(jsonResponse(pluginList("good", "npm:good@^1"))) + .mockResolvedValueOnce(Response.json(pluginList("good", "npm:good@^1"))) .mockResolvedValueOnce( - jsonResponse({ + Response.json({ applied: true, from: version("1"), to: version("2"), @@ -325,7 +318,7 @@ describe("bb plugin update commands", () => { it("renders a rolled-back server result", async () => { vi.mocked(fetch) .mockResolvedValueOnce( - jsonResponse({ + Response.json({ results: [ { id: "notes", @@ -336,9 +329,9 @@ describe("bb plugin update commands", () => { ], }), ) - .mockResolvedValueOnce(jsonResponse(pluginList("notes", "npm:notes@^1"))) + .mockResolvedValueOnce(Response.json(pluginList("notes", "npm:notes@^1"))) .mockResolvedValueOnce( - jsonResponse({ + Response.json({ applied: false, from: version("1"), to: version("2"), @@ -366,10 +359,13 @@ describe("bb plugin update commands", () => { ], }; vi.mocked(fetch) - .mockResolvedValueOnce(jsonResponse(update)) - .mockResolvedValueOnce(jsonResponse(pluginList("notes", "npm:notes@^1"))) + .mockResolvedValueOnce(Response.json(update)) + .mockResolvedValueOnce(Response.json(pluginList("notes", "npm:notes@^1"))) .mockResolvedValueOnce( - jsonResponse({ error: "signature verification failed" }, 422), + Response.json( + { error: "signature verification failed" }, + { status: 422 }, + ), ); await expect( runCommand(["plugin", "update", "notes", "--yes"], register), @@ -384,8 +380,10 @@ describe("bb plugin update commands", () => { configurable: true, }); vi.mocked(fetch) - .mockResolvedValueOnce(jsonResponse(update)) - .mockResolvedValueOnce(jsonResponse(pluginList("notes", "npm:notes@^1"))); + .mockResolvedValueOnce(Response.json(update)) + .mockResolvedValueOnce( + Response.json(pluginList("notes", "npm:notes@^1")), + ); await expect( runCommand(["plugin", "update", "notes"], register), ).rejects.toThrow("process.exit:1"); diff --git a/apps/cli/src/__tests__/command-output/server-export.test.ts b/apps/cli/src/__tests__/command-output/server-export.test.ts index 198868ad676..f8cd9fb05de 100644 --- a/apps/cli/src/__tests__/command-output/server-export.test.ts +++ b/apps/cli/src/__tests__/command-output/server-export.test.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import { collectLogPayloads, runCommand, @@ -12,14 +12,6 @@ import { import type { CommandRegistrar } from "../helpers/command-output-harness.js"; import { registerServerCommands } from "../../commands/server.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), "bb-cli-server-export-")); - tempDirs.push(dir); - return dir; -} - function streamOf(chunks: readonly Uint8Array[], failure?: Error) { let index = 0; return new ReadableStream({ @@ -66,14 +58,6 @@ const UNENCRYPTED_EXPORT_WARNING = describe("bb server export", () => { setupCommandOutputTestEnvironment(); - afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => rm(dir, { force: true, recursive: true })), - ); - }); - const register: CommandRegistrar = (program) => registerServerCommands( program.enablePositionalOptions(), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 96e895fbbb7..23c30d364f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,7 @@ "@bb/domain": "workspace:*", "@bb/server-archive": "workspace:*", "@bb/server-contract": "workspace:*", + "@bb/test-helpers": "workspace:*", "@bb/text-utils": "workspace:*", "@bb/tsconfig": "workspace:*", "@types/node": "^24.9.0", diff --git a/apps/desktop/test/desktop-browser-cdp.test.ts b/apps/desktop/test/desktop-browser-cdp.test.ts index f459ccbbe83..e3cd84e3224 100644 --- a/apps/desktop/test/desktop-browser-cdp.test.ts +++ b/apps/desktop/test/desktop-browser-cdp.test.ts @@ -9,21 +9,12 @@ import { type DesktopBrowserCdpPage, type DesktopBrowserCdpScope, } from "../src/desktop-browser-cdp.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const messageSchema = z.record(z.string(), z.json()); type Message = z.infer; const disposers: Array<() => Promise> = []; -function deferred() { - let resolve: () => void = () => { - throw new Error("Deferred promise is not initialized"); - }; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - afterEach(async () => { vi.unstubAllGlobals(); for (const dispose of disposers.splice(0).reverse()) await dispose(); @@ -521,8 +512,8 @@ describe("thread-scoped browser CDP", () => { await openBridge(); const page = tabs.get("a"); if (page === undefined) throw new Error("Missing fixture page"); - const entered = deferred(); - const ready = deferred(); + const entered = createDeferredPromise(); + const ready = createDeferredPromise(); const originalSend = page.send; page.send = async (command, params, sessionId, signal) => { if (command === method) { @@ -609,9 +600,9 @@ describe("thread-scoped browser CDP", () => { "aborts delayed %s callbacks before they mutate tabs after lease replacement", async (operation) => { const { bridge, adapter, scopeA, tabs } = await openBridge(); - const entered = deferred(); - const release = deferred(); - const finished = deferred(); + const entered = createDeferredPromise(); + const release = createDeferredPromise(); + const finished = createDeferredPromise(); let mutations = 0; let aborted = false; async function beforeMutation(signal: AbortSignal) { diff --git a/apps/desktop/test/server-moved.test.ts b/apps/desktop/test/server-moved.test.ts index 75f3a29aeab..7fbf1ce26b7 100644 --- a/apps/desktop/test/server-moved.test.ts +++ b/apps/desktop/test/server-moved.test.ts @@ -28,6 +28,7 @@ import { createServerTargetStore, type ServerTargetFs, } from "../src/server-target.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const tempDirs: string[] = []; @@ -449,14 +450,6 @@ describe("createServerMovedWatcher", () => { }; } - function createDeferred() { - let resolveDeferred: (value: T) => void = () => undefined; - const promise = new Promise((resolvePromise) => { - resolveDeferred = resolvePromise; - }); - return { promise, resolve: resolveDeferred }; - } - type ConfirmMove = ( move: DesktopServerMove, isCancelled: () => boolean, @@ -574,7 +567,7 @@ describe("createServerMovedWatcher", () => { }); it("checks again when the lock changes during a confirmation", async () => { - const first = createDeferred(); + const first = createDeferredPromise(); const harness = await createHarness({ confirmMove: () => first.promise }); await writeServerMovedFile(harness.dataDir, movedFile()); try { @@ -651,7 +644,7 @@ describe("createServerMovedWatcher", () => { }); it("cancels a confirmation that is still running when stopped", async () => { - const confirmation = createDeferred(); + const confirmation = createDeferredPromise(); const cancellationChecks: Array<() => boolean> = []; const harness = await createHarness({ confirmMove: (_move, isCancelled) => { @@ -1154,26 +1147,19 @@ describe("waitForServerMoveDestination", () => { }; } - function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - status, - }); - } - it("waits until the new server answers outside pending mode", async () => { const clock = createClock(); const fetchImpl = vi .fn<(input: string) => Promise>() .mockRejectedValueOnce(new Error("ECONNREFUSED")) .mockResolvedValueOnce( - jsonResponse({ + Response.json({ ok: true, serverMove: { moveId: "m", state: "pending" }, }), ) - .mockResolvedValueOnce(jsonResponse({ ok: false }, 503)) - .mockResolvedValueOnce(jsonResponse({ ok: true })); + .mockResolvedValueOnce(Response.json({ ok: false }, { status: 503 })) + .mockResolvedValueOnce(Response.json({ ok: true })); await expect( waitForServerMoveDestination({ diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts index 117c742b70e..c59968a3e87 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import { execFileSync } from "node:child_process"; -import os from "node:os"; import path from "node:path"; import type { AgentRuntime, AgentRuntimeOptions } from "@bb/agent-runtime"; import { @@ -16,7 +15,7 @@ import { type HostDaemonContributedEnvEntry, } from "@bb/host-daemon-contract"; import type { HostWatcher } from "@bb/host-watcher"; -import { createDeferredPromise } from "@bb/test-helpers"; +import { createDeferredPromise, makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DISPATCH_TEST_BRIDGE_LAUNCH, @@ -76,8 +75,6 @@ type StartIdleProviderSessionReaperArgsForTest = Parameters< typeof startIdleProviderSessionReaper >[0]; -const tempDirs: string[] = []; - function createLogger() { return { debug: vi.fn(), @@ -87,12 +84,6 @@ function createLogger() { } satisfies HostDaemonLogger; } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - function readFetchUrl(input: RequestInfo | URL): URL { if (typeof input === "string") { return new URL(input); @@ -376,14 +367,9 @@ async function settleReaperPromiseChain(): Promise { } } -afterEach(async () => { +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); async function createAppFixture( diff --git a/apps/host-daemon/src/auth-state.test.ts b/apps/host-daemon/src/auth-state.test.ts index d498e053088..06cef5bb4d3 100644 --- a/apps/host-daemon/src/auth-state.test.ts +++ b/apps/host-daemon/src/auth-state.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { HOST_AUTH_FILE_NAME } from "@bb/host-daemon-contract"; import { readHostAuthState, @@ -9,22 +9,6 @@ import { writeHostAuthState, } from "./auth-state.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - describe("auth state", () => { it("returns the normalized provided server URL", () => { expect( diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index b31e0205760..1fd44160ac4 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { AgentRuntime } from "@bb/agent-runtime"; import type { @@ -8,8 +7,8 @@ import type { ProviderCliStatus, } from "@bb/host-daemon-contract"; import type { HostWorkspace } from "@bb/host-workspace"; -import { createDeferredPromise } from "@bb/test-helpers"; -import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; +import { createDeferredPromise, makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi, type Mock } from "vitest"; import { dispatchCommand, dispatchOnlineRpcCommand, @@ -43,22 +42,6 @@ interface BusySkillCatalogFixture { source: HostDaemonInjectedSkillSource; } -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - async function writeInjectedSkillSource( args: WriteInjectedSkillSourceArgs, ): Promise { diff --git a/apps/host-daemon/src/command-handlers/file-write.test.ts b/apps/host-daemon/src/command-handlers/file-write.test.ts index 61727abf15d..733a340eea4 100644 --- a/apps/host-daemon/src/command-handlers/file-write.test.ts +++ b/apps/host-daemon/src/command-handlers/file-write.test.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { CommandDispatchError, type CommandOf, @@ -11,22 +11,6 @@ import { import { writeHostFile } from "./file-write.js"; import { readHostFile } from "./host-files.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - function sha256(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } diff --git a/apps/host-daemon/src/command-handlers/host-files.test.ts b/apps/host-daemon/src/command-handlers/host-files.test.ts index 699b2e8bb91..31aca6899a7 100644 --- a/apps/host-daemon/src/command-handlers/host-files.test.ts +++ b/apps/host-daemon/src/command-handlers/host-files.test.ts @@ -4,7 +4,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, describe, expect, it } from "vitest"; +import { initRepo, makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { CommandDispatchError, type CommandOf, @@ -18,14 +19,6 @@ import { } from "./host-files.js"; const execFileAsync = promisify(execFile); -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function runGit( args: readonly string[], options: { cwd: string }, @@ -34,14 +27,6 @@ async function runGit( return result.stdout; } -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-host-files-test-"); - await runGit(["init", "-b", "main"], { cwd: repoPath }); - await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); - await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); - return repoPath; -} - async function captureReadHostFileError( command: CommandOf<"host.read_file">, ): Promise { @@ -66,18 +51,11 @@ async function captureReadHostRelativeFileError( throw new Error("Expected readHostRelativeFile to fail"); } -afterEach(async () => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - await fs.rm(dir, { recursive: true, force: true }); - } - } -}); - describe("readHostFile (no ref — disk read)", () => { it("reads explicit file contents from disk without a rootPath", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const filePath = path.join(repoPath, "host-notes.md"); await fs.writeFile(filePath, "host notes", "utf8"); @@ -93,7 +71,9 @@ describe("readHostFile (no ref — disk read)", () => { }); it("reads file contents from disk", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const filePath = path.join(repoPath, "hello.txt"); await fs.writeFile(filePath, "hello world", "utf8"); @@ -109,7 +89,9 @@ describe("readHostFile (no ref — disk read)", () => { }); it("omits unchanged file content from conditional reads", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const filePath = path.join(repoPath, "large.png"); const contents = Buffer.alloc(1024, "a"); const sha256 = createHash("sha256").update(contents).digest("hex"); @@ -155,7 +137,9 @@ describe("readHostFile (no ref — disk read)", () => { }); it("marks missing targets under an existing root as expected", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const missingPath = path.join(repoPath, "notes.md"); const thrown = await captureReadHostFileError({ type: "host.read_file", @@ -173,7 +157,9 @@ describe("readHostFile (no ref — disk read)", () => { }); it("marks missing rootless targets as expected", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const missingPath = path.join(repoPath, "HOST-NOTES.md"); const thrown = await captureReadHostFileError({ type: "host.read_file", @@ -225,7 +211,9 @@ describe("readHostFile (no ref — disk read)", () => { }); it("rejects rootless directory paths", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await expect( readHostFile({ @@ -325,7 +313,9 @@ describe("browseHostDirectory", () => { describe("readHostFile (with ref — git history read)", () => { it("rejects ref reads without rootPath", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await expect( readHostFile({ @@ -340,7 +330,9 @@ describe("readHostFile (with ref — git history read)", () => { }); it("returns empty content when the file does not exist at the ref", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await fs.writeFile(path.join(repoPath, "seed.txt"), "seed\n", "utf8"); await runGit(["add", "seed.txt"], { cwd: repoPath }); await runGit(["commit", "-m", "seed"], { cwd: repoPath }); @@ -357,7 +349,9 @@ describe("readHostFile (with ref — git history read)", () => { }); it("rejects unsafe refs (path traversal)", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await fs.writeFile(path.join(repoPath, "f.txt"), "x", "utf8"); await runGit(["add", "."], { cwd: repoPath }); await runGit(["commit", "-m", "init"], { cwd: repoPath }); @@ -373,7 +367,9 @@ describe("readHostFile (with ref — git history read)", () => { }); it("rejects refs with leading dash", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await expect( readHostFile({ type: "host.read_file", @@ -385,7 +381,9 @@ describe("readHostFile (with ref — git history read)", () => { }); it("rejects paths outside rootPath", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); await fs.writeFile(path.join(repoPath, "seed.txt"), "x", "utf8"); await runGit(["add", "."], { cwd: repoPath }); await runGit(["commit", "-m", "init"], { cwd: repoPath }); @@ -401,7 +399,9 @@ describe("readHostFile (with ref — git history read)", () => { }); it("reads at a commit SHA other than HEAD", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-host-files-test-"), { + commit: false, + }); const filePath = path.join(repoPath, "tracked.txt"); await fs.writeFile(filePath, "first\n", "utf8"); await runGit(["add", "."], { cwd: repoPath }); diff --git a/apps/host-daemon/src/command-handlers/install-global-skills.test.ts b/apps/host-daemon/src/command-handlers/install-global-skills.test.ts index a2c415dea94..6c239dec075 100644 --- a/apps/host-daemon/src/command-handlers/install-global-skills.test.ts +++ b/apps/host-daemon/src/command-handlers/install-global-skills.test.ts @@ -1,15 +1,8 @@ import { createHash } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import type { HostDaemonSkillTree } from "@bb/host-daemon-contract"; import { installGlobalSkills, @@ -18,20 +11,6 @@ import { const logger = { debug: () => undefined, warn: () => undefined }; -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), - ); -}); - -async function makeTempDir(): Promise { - const dir = await mkdtemp(path.join(tmpdir(), "bb-install-global-skills-")); - tempDirs.push(dir); - return dir; -} - function createTreePayload(name: string, body: string): HostDaemonSkillTree { const entries = [ { diff --git a/apps/host-daemon/src/command-handlers/workspace-path-list.test.ts b/apps/host-daemon/src/command-handlers/workspace-path-list.test.ts index c02da70d744..e09cc9ec54d 100644 --- a/apps/host-daemon/src/command-handlers/workspace-path-list.test.ts +++ b/apps/host-daemon/src/command-handlers/workspace-path-list.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { runGit } from "@bb/host-workspace"; +import { initRepo } from "@bb/test-helpers"; import { listWorkspacePaths } from "./file-list.js"; import { listHostPaths } from "./host-files.js"; @@ -20,10 +21,6 @@ async function write(root: string, file: string, content = "fixture") { await fs.writeFile(path.join(root, file), content); } -async function initRepo(root: string) { - await runGit(["init", "-b", "main"], { cwd: root }); -} - function listingArgs(root: string) { return { root, @@ -53,7 +50,7 @@ describe("workspace path discovery", () => { it("includes tracked and non-ignored untracked files while pruning Git-ignored trees", async () => { const fileNameBreak = process.platform === "win32" ? " " : "\n"; const root = await createRoot(); - await initRepo(root); + await initRepo(root, { commit: false }); await write(root, ".github/workflows/ci.yml"); await write(root, ".generated/tracked.txt"); await runGit(["add", "."], { cwd: root }); @@ -95,7 +92,7 @@ describe("workspace path discovery", () => { it("resolves ignore rules relative to a subdirectory and a linked worktree", async () => { const root = await createRoot(); - await initRepo(root); + await initRepo(root, { commit: false }); await write(root, ".gitignore", "/.sandboxes/\n*.log\n"); await write(root, "src/README.md"); await runGit(["add", "."], { cwd: root }); @@ -144,7 +141,7 @@ describe("workspace path discovery", () => { "src/worktrees/index.ts", ], ); - await initRepo(root); + await initRepo(root, { commit: false }); expect(await paths(root)).not.toContain("output/result.txt"); expect( (await listWorkspacePaths({ ...args, respectGitIgnore: false })).map( @@ -160,7 +157,7 @@ describe("workspace path discovery", () => { it("shares pending discovery but refreshes completed results and ignore rules", async () => { const root = await createRoot(); - await initRepo(root); + await initRepo(root, { commit: false }); await write(root, "first.txt"); const args = listingArgs(root); const first = listWorkspacePaths(args); @@ -190,7 +187,7 @@ describe("workspace path discovery", () => { it("ranks concurrent queries independently over the shared listing", async () => { const root = await createRoot(); - await initRepo(root); + await initRepo(root, { commit: false }); await write(root, "alpha.md"); await write(root, "beta.md"); const command = { diff --git a/apps/host-daemon/src/daemon.test.ts b/apps/host-daemon/src/daemon.test.ts index a80a5325b7b..345278cad51 100644 --- a/apps/host-daemon/src/daemon.test.ts +++ b/apps/host-daemon/src/daemon.test.ts @@ -1,12 +1,11 @@ import { HOST_DAEMON_RESTART_EXIT_CODE } from "@bb/config/machine-service"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import { createDaemon } from "./daemon.js"; import { acquireDaemonLock, DAEMON_LOCK_FILE_NAME } from "./lock.js"; -const tempDirs: string[] = []; type SignalListener = () => void; class FakeSignalSource { @@ -29,12 +28,6 @@ class FakeSignalSource { } } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - function createLogger() { return { debug: vi.fn(), @@ -44,14 +37,6 @@ function createLogger() { }; } -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - describe("daemon lifecycle", () => { it("prevents a second instance from acquiring the lock", async () => { const dataDir = await makeTempDir("bb-host-daemon-lock-"); diff --git a/apps/host-daemon/src/identity.test.ts b/apps/host-daemon/src/identity.test.ts index 00c431df69b..18a62a587e1 100644 --- a/apps/host-daemon/src/identity.test.ts +++ b/apps/host-daemon/src/identity.test.ts @@ -1,17 +1,9 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { detectHostName, loadHostIdentity, persistHostId } from "./identity.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function readFileOrNull(filePath: string): Promise { try { return await fs.readFile(filePath, "utf8"); @@ -25,14 +17,6 @@ async function readFileOrNull(filePath: string): Promise { } } -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - describe("identity", () => { it("resolves a host ID once persisted and reuses it on subsequent runs", async () => { const dataDir = await makeTempDir("bb-host-daemon-identity-"); diff --git a/apps/host-daemon/src/injected-skills.test.ts b/apps/host-daemon/src/injected-skills.test.ts index f3ee1f1fd47..c6aa764831a 100644 --- a/apps/host-daemon/src/injected-skills.test.ts +++ b/apps/host-daemon/src/injected-skills.test.ts @@ -2,7 +2,6 @@ import { access, lstat, mkdir, - mkdtemp, readFile, readdir, rm, @@ -12,9 +11,9 @@ import { } from "node:fs/promises"; import fs from "node:fs/promises"; import { createHash } from "node:crypto"; -import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import type { AgentRuntimeSkillRoot } from "@bb/agent-runtime"; import type { HostDaemonInjectedSkillSource, @@ -44,20 +43,6 @@ interface CapturedWarning { message: string; } -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const dir = await mkdtemp(path.join(tmpdir(), "bb-host-skills-")); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), - ); -}); - function requireSkillRoot( roots: readonly AgentRuntimeSkillRoot[], ): AgentRuntimeSkillRoot { diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index c1f1a2f44cb..0bc74658a5b 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -15,6 +15,8 @@ import { } from "@bb/host-workspace"; import { createDeferredPromise, + initRepo, + makeTempDir, makeWorkspaceMergeBase, makeWorkspaceStatus, } from "@bb/test-helpers"; @@ -68,14 +70,6 @@ interface RuntimeManagerProviderMaintenanceInternals { } const execFileAsync = promisify(execFile); -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function runGit( args: readonly string[], options: RunGitOptions, @@ -86,17 +80,6 @@ async function runGit( return result.stdout; } -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-runtime-manager-repo-"); - await runGit(["init", "-b", "main"], { cwd: repoPath }); - await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); - await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); - await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); - await runGit(["add", "."], { cwd: repoPath }); - await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath }); - return repoPath; -} - async function writeInjectedSkillSource( args: WriteInjectedSkillSourceArgs, ): Promise> { @@ -125,13 +108,8 @@ async function writeInjectedSkillSource( }; } -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); function getProvisionWorkspacePath(args: ProvisionWorkspaceArgs): string { @@ -991,7 +969,9 @@ describe("RuntimeManager", () => { }); it("passes unmanaged linked worktree git metadata roots to created runtimes", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await makeTempDir("bb-runtime-manager-repo-"), + ); const parentDir = await makeTempDir("bb-runtime-manager-unmanaged-wt-"); const worktreePath = path.join(parentDir, "env"); await runGit(["worktree", "add", "-B", "bb/unmanaged", worktreePath], { diff --git a/apps/host-daemon/src/runtime-shell-env.test.ts b/apps/host-daemon/src/runtime-shell-env.test.ts index 29f63b90565..6dcf2ec59b3 100644 --- a/apps/host-daemon/src/runtime-shell-env.test.ts +++ b/apps/host-daemon/src/runtime-shell-env.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path, { delimiter } from "node:path"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createUserShellPathResolver, @@ -12,14 +12,6 @@ import { type UserShellEnvSpawnResult, } from "./runtime-shell-env.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(directoryPath); - return directoryPath; -} - interface FakeCliPackageOptions { executablePath?: string; executable?: boolean; @@ -143,15 +135,8 @@ function createFakeShellEnvSpawn( }; } -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await Promise.all( - tempDirs - .splice(0) - .map((directoryPath) => - fs.rm(directoryPath, { recursive: true, force: true }), - ), - ); }); describe("resolveLocalBbExecutablePath", () => { diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts index 98c1fdef173..d7e6003d3e3 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.test.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts @@ -6,6 +6,7 @@ import type { HostDaemonDaemonWsMessage } from "@bb/host-daemon-contract"; import type { HostWorkspace } from "@bb/host-workspace"; import { createDeferredPromise, + makeTempDir, makeWorkspaceMergeBase, makeWorkspaceStatus, } from "@bb/test-helpers"; @@ -24,7 +25,6 @@ import { type TerminalPtyProcess, } from "./terminal-manager.js"; -const tempDirs: string[] = []; const DEFAULT_TERMINAL_START = { mode: "shell" } as const; interface ResizeCall { @@ -78,12 +78,6 @@ type TerminalOutputChunk = Extract< type SteerTurnResult = Awaited>; -async function makeTempDir(prefix: string): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(tempDir); - return tempDir; -} - async function writeEmptyFile(filePath: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, ""); @@ -98,14 +92,6 @@ function createFakeLogger(): HostDaemonLogger { }; } -async function cleanupTempDirs(): Promise { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => fs.rm(tempDir, { force: true, recursive: true })), - ); -} - class FakeTerminalPty implements TerminalPtyProcess { disposeCount: number; exited = false; @@ -478,10 +464,9 @@ function textChunk(text: string, seq: number): TerminalOutputChunk { } describe("TerminalManager", () => { - afterEach(async () => { + afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); - await cleanupTempDirs(); }); it("opens a PTY in the workspace and keeps the environment active", async () => { diff --git a/apps/host-daemon/src/thread-storage-root.test.ts b/apps/host-daemon/src/thread-storage-root.test.ts index cdc77b30000..b48121a67c8 100644 --- a/apps/host-daemon/src/thread-storage-root.test.ts +++ b/apps/host-daemon/src/thread-storage-root.test.ts @@ -1,24 +1,11 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureThreadStorageRoot } from "./thread-storage-root.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await Promise.all( - tempDirs.splice(0).map(async (dir) => { - await fs.rm(dir, { force: true, recursive: true }); - }), - ); }); describe("thread storage root", () => { diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts index fcc54d82a5c..80613e6c777 100644 --- a/apps/host-daemon/test/command/dispatch-helpers.ts +++ b/apps/host-daemon/test/command/dispatch-helpers.ts @@ -1,7 +1,5 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import type { @@ -27,7 +25,6 @@ import { RuntimeManager } from "../../src/runtime-manager.js"; import type { CommandDispatchOptions } from "../../src/command-dispatch-support.js"; import type { FetchProjectAttachment } from "../../src/project-attachments.js"; -const tempDirs: string[] = []; const execFileAsync = promisify(execFile); export const noopEventSink: CommandDispatchOptions["eventSink"] = { emit: () => undefined, @@ -512,12 +509,6 @@ export function makeDispatchOptions( }; } -export async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - export async function runGitCommand( args: GitCommandArgs, options: RunGitCommandOptions, @@ -525,19 +516,6 @@ export async function runGitCommand( await execFileAsync("git", args, { cwd: options.cwd }); } -export async function cleanupTempDirs(): Promise { - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 100, - }), - ), - ); -} - export const DISPATCH_TEST_ARTIFACT_BYTES = Buffer.from( "export const bridge = true;\n", ); diff --git a/apps/host-daemon/test/command/environment-dispatch.test.ts b/apps/host-daemon/test/command/environment-dispatch.test.ts index 7b9498521e7..dd932c15f63 100644 --- a/apps/host-daemon/test/command/environment-dispatch.test.ts +++ b/apps/host-daemon/test/command/environment-dispatch.test.ts @@ -1,20 +1,17 @@ import fs from "node:fs/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { WorkspaceError, type HostWorkspace } from "@bb/host-workspace"; import { dispatchCommand } from "../../src/command-dispatch.js"; import type { EventSinkInput } from "../../src/event-sink.js"; import { - cleanupTempDirs, createFakeRuntime, createFakeWorkspace, createHarness, makeDispatchOptions, - makeTempDir, } from "./dispatch-helpers.js"; import { RuntimeManager } from "../../src/runtime-manager.js"; -afterEach(cleanupTempDirs); - function streamedEntries(emitted: EventSinkInput[]) { return emitted.flatMap((input) => input.event.type === "system/thread-provisioning" diff --git a/apps/host-daemon/test/command/environment-hook-unverified-stop.test.ts b/apps/host-daemon/test/command/environment-hook-unverified-stop.test.ts index 71cf7986eaa..0dfb28f4528 100644 --- a/apps/host-daemon/test/command/environment-hook-unverified-stop.test.ts +++ b/apps/host-daemon/test/command/environment-hook-unverified-stop.test.ts @@ -1,12 +1,9 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { expect, it, vi } from "vitest"; import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js"; -import { - cleanupTempDirs, - createHarness, - makeTempDir, -} from "./dispatch-helpers.js"; +import { createHarness } from "./dispatch-helpers.js"; vi.mock("../../src/environment-lifecycle-script.js", async (importOriginal) => { const actual = @@ -28,8 +25,6 @@ vi.mock("../../src/environment-lifecycle-script.js", async (importOriginal) => { }; }); -afterEach(cleanupTempDirs); - it("answers unknown when a cancelled hook's processes could not be confirmed stopped", async () => { const path = await makeTempDir("bb-hook-unverified-"); await writeFile(join(path, ".bb-env-setup.sh"), "sleep 120\n"); diff --git a/apps/host-daemon/test/command/environment-hook.test.ts b/apps/host-daemon/test/command/environment-hook.test.ts index ccb9251b47e..cae1530a6cd 100644 --- a/apps/host-daemon/test/command/environment-hook.test.ts +++ b/apps/host-daemon/test/command/environment-hook.test.ts @@ -1,14 +1,9 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { expect, it } from "vitest"; import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js"; -import { - cleanupTempDirs, - createHarness, - makeTempDir, -} from "./dispatch-helpers.js"; - -afterEach(cleanupTempDirs); +import { createHarness } from "./dispatch-helpers.js"; it("streams hook output and cancels the process before the run RPC settles", async () => { const harness = createHarness(); diff --git a/apps/host-daemon/test/command/host-branches-dispatch.test.ts b/apps/host-daemon/test/command/host-branches-dispatch.test.ts index 0aa844b2918..db5381d38fe 100644 --- a/apps/host-daemon/test/command/host-branches-dispatch.test.ts +++ b/apps/host-daemon/test/command/host-branches-dispatch.test.ts @@ -1,15 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js"; -import { - cleanupTempDirs, - createHarness, - makeTempDir, - runGitCommand, -} from "./dispatch-helpers.js"; - -afterEach(cleanupTempDirs); +import { createHarness, runGitCommand } from "./dispatch-helpers.js"; function gitShellPath(filePath: string): string { return filePath.replaceAll("\\", "/"); diff --git a/apps/host-daemon/test/command/project-clone-private-env.test.ts b/apps/host-daemon/test/command/project-clone-private-env.test.ts index 430f6e15721..b7b78c0e4a4 100644 --- a/apps/host-daemon/test/command/project-clone-private-env.test.ts +++ b/apps/host-daemon/test/command/project-clone-private-env.test.ts @@ -1,16 +1,12 @@ import { writeFile, readFile } from "node:fs/promises"; import { join } from "node:path"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, expect, it, vi } from "vitest"; import { dispatchCommand } from "../../src/command-dispatch.js"; -import { - cleanupTempDirs, - createHarness, - makeTempDir, -} from "./dispatch-helpers.js"; +import { createHarness } from "./dispatch-helpers.js"; -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await cleanupTempDirs(); }); it("strips daemon-private inherited variables and returns clone failures as-is", async () => { const dir = await makeTempDir("bb-clone-private-"); diff --git a/apps/host-daemon/test/command/thread-dispatch.test.ts b/apps/host-daemon/test/command/thread-dispatch.test.ts index 011909aa474..b9ddf2e6a4d 100644 --- a/apps/host-daemon/test/command/thread-dispatch.test.ts +++ b/apps/host-daemon/test/command/thread-dispatch.test.ts @@ -11,23 +11,20 @@ import { type ClientTurnRequestId, type PromptInput, } from "@bb/domain"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import { dispatchCommand } from "../../src/command-dispatch.js"; import { CommandDispatchError } from "../../src/command-dispatch-support.js"; import type { FetchProjectAttachment } from "../../src/project-attachments.js"; import { RuntimeManager } from "../../src/runtime-manager.js"; import { - cleanupTempDirs, createFakeRuntime, createFakeWorkspace, createHarness, makeDispatchOptions, - makeTempDir, DISPATCH_TEST_BRIDGE_LAUNCH, } from "./dispatch-helpers.js"; -afterEach(cleanupTempDirs); - let nextClientRequestIdValue = 1; type TextPromptInput = Extract; diff --git a/apps/host-daemon/test/command/thread-stop-races.test.ts b/apps/host-daemon/test/command/thread-stop-races.test.ts index fefc40c5079..97c0af2b656 100644 --- a/apps/host-daemon/test/command/thread-stop-races.test.ts +++ b/apps/host-daemon/test/command/thread-stop-races.test.ts @@ -22,6 +22,7 @@ import type { HostDaemonBridgeLaunch, HostDaemonOnlineRpcResponseMessage, } from "@bb/host-daemon-contract"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { dispatchCommand } from "../../src/command-dispatch.js"; import { @@ -32,10 +33,8 @@ import { import { CommandRouter } from "../../src/command-router.js"; import { RuntimeManager } from "../../src/runtime-manager.js"; import { - cleanupTempDirs, createFakeWorkspace, makeDispatchOptions, - makeTempDir, noopEventSink, unexpectedProjectAttachmentFetch, unexpectedProviderMaintenance, @@ -75,7 +74,6 @@ let nextRpcRequestIdValue = 1; afterEach(async () => { vi.useRealTimers(); await Promise.all(managers.splice(0).map((manager) => manager.shutdownAll())); - await cleanupTempDirs(); }); function nextClientRequestId(): ClientTurnRequestId { diff --git a/apps/host-daemon/test/command/workspace-dispatch.test.ts b/apps/host-daemon/test/command/workspace-dispatch.test.ts index d2861c11f14..03aee2c2169 100644 --- a/apps/host-daemon/test/command/workspace-dispatch.test.ts +++ b/apps/host-daemon/test/command/workspace-dispatch.test.ts @@ -1,21 +1,18 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { GitHostPullRequest } from "@bb/domain"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { dispatchCommand, dispatchOnlineRpcCommand, } from "../../src/command-dispatch.js"; import { - cleanupTempDirs, createFakeWorkspace, createHarness, - makeTempDir, runGitCommand, } from "./dispatch-helpers.js"; -afterEach(cleanupTempDirs); - describe("workspace command dispatch", () => { it("covers workspace git commands", async () => { const harness = createHarness(); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6bb79ed7be2..012a3e97ef2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -69,6 +69,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@types/culori": "^4.0.1", diff --git a/apps/mobile/src/data/connect/enrollment.test.ts b/apps/mobile/src/data/connect/enrollment.test.ts index caae9bb4f12..7dea41e1f39 100644 --- a/apps/mobile/src/data/connect/enrollment.test.ts +++ b/apps/mobile/src/data/connect/enrollment.test.ts @@ -6,17 +6,10 @@ import { redeemEnrollment, } from "./enrollment"; -function jsonResponse(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - describe("redeemEnrollment", () => { it("redeems at the apex and shapes a connect profile labelled by handle", async () => { const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse(200, { + Response.json({ credential: "bbcm_secret", machineId: "m1", handle: "account", @@ -52,7 +45,7 @@ describe("redeemEnrollment", () => { const limit = vi .fn() .mockImplementation(async () => - jsonResponse(409, { error: "machine-limit" }), + Response.json({ error: "machine-limit" }, { status: 409 }), ); await expect( redeemEnrollment( @@ -84,7 +77,7 @@ describe("redeemEnrollment", () => { }, vi .fn() - .mockResolvedValue(jsonResponse(status, { error: wire })), + .mockResolvedValue(Response.json({ error: wire }, { status })), ).catch((error: unknown) => describeEnrollmentError(error)); expect(failed).toMatchObject({ code }); } diff --git a/apps/mobile/src/data/notifications/push-registration-controller.test.ts b/apps/mobile/src/data/notifications/push-registration-controller.test.ts index 872fc698505..18cd9367e9a 100644 --- a/apps/mobile/src/data/notifications/push-registration-controller.test.ts +++ b/apps/mobile/src/data/notifications/push-registration-controller.test.ts @@ -3,18 +3,11 @@ import { createPushRegistrationController } from "./push-registration-controller import type { PushNotificationsModule } from "./push-registration"; import { createMemoryPushStorage, createPushStore } from "./push-store"; import type { PushSubscriptionsApi } from "./push-subscriptions-api"; - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} +import { createDeferredPromise } from "@bb/test-helpers"; function setup() { const store = createPushStore(createMemoryPushStorage()); - const tokenGate = deferred(); + const tokenGate = createDeferredPromise(); let tokenListener: ((deviceToken: string) => void) | null = null; const notifications: PushNotificationsModule = { projectId: "eas", @@ -140,7 +133,7 @@ describe("createPushRegistrationController", () => { await tokenEvents.at(-1); expect(notifications.getExpoPushToken).toHaveBeenCalledTimes(2); - const activeTokenGate = deferred(); + const activeTokenGate = createDeferredPromise(); vi.mocked(notifications.getExpoPushToken).mockImplementationOnce( () => activeTokenGate.promise, ); @@ -199,7 +192,7 @@ describe("device push token consent", () => { const { controller, notifications, tokenGate } = setup(); tokenGate.resolve("old-token"); await controller.setEnabled(profile, true); - const deletion = deferred(); + const deletion = createDeferredPromise(); vi.mocked(notifications.unregisterDevicePushToken).mockImplementationOnce( () => deletion.promise, ); diff --git a/apps/mobile/src/data/notifications/push-subscriptions-api.test.ts b/apps/mobile/src/data/notifications/push-subscriptions-api.test.ts index a3c12baef3d..38596197193 100644 --- a/apps/mobile/src/data/notifications/push-subscriptions-api.test.ts +++ b/apps/mobile/src/data/notifications/push-subscriptions-api.test.ts @@ -4,13 +4,6 @@ import { PUSH_NOTIFICATIONS_PLUGIN_DISABLED_STATUS, } from "./push-subscriptions-api"; -function jsonResponse(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - const subscriptionRef = { subscriptionId: "sub_1", expoPushToken: "ExponentPushToken[abc]", @@ -20,7 +13,7 @@ const subscriptionRef = { describe("createPushSubscriptionsApi", () => { it("registers through the push-notifications plugin RPC", async () => { const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse(200, { + Response.json({ ok: true, result: { id: "sub_1", created: true }, }), @@ -49,13 +42,16 @@ describe("createPushSubscriptionsApi", () => { it("treats a missing row as already removed", async () => { const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse(500, { - ok: false, - error: { - code: "handler_error", - message: "Push subscription not found: sub_1", + Response.json( + { + ok: false, + error: { + code: "handler_error", + message: "Push subscription not found: sub_1", + }, }, - }), + { status: 500 }, + ), ); const api = createPushSubscriptionsApi(fetchImpl); @@ -74,10 +70,13 @@ describe("createPushSubscriptionsApi", () => { "maps the unavailable plugin response to the settings status", async ({ status, message }) => { const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse(status, { - ok: false, - error: message, - }), + Response.json( + { + ok: false, + error: message, + }, + { status }, + ), ); const api = createPushSubscriptionsApi(fetchImpl); diff --git a/apps/mobile/src/lib/sdk/mobile-fetch.test.ts b/apps/mobile/src/lib/sdk/mobile-fetch.test.ts index 9f96084911b..df8d3b4eaf5 100644 --- a/apps/mobile/src/lib/sdk/mobile-fetch.test.ts +++ b/apps/mobile/src/lib/sdk/mobile-fetch.test.ts @@ -14,13 +14,6 @@ const MOVED_BODY = { }, }; -function jsonResponse(body: unknown, status: number): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - function setup(response: () => Response) { const baseFetch = vi.fn(async () => response()); const onAuthFailure = vi.fn<(status: number) => void>(); @@ -35,7 +28,7 @@ function setup(response: () => Response) { describe("createMobileFetch", () => { it("reports a server_moved 410 and leaves the body readable for the caller", async () => { const { mobileFetch, onAuthFailure, onServerMoved } = setup(() => - jsonResponse(MOVED_BODY, 410), + Response.json(MOVED_BODY, { status: 410 }), ); const response = await mobileFetch("http://192.168.1.20:38886/api/v1/x"); @@ -56,16 +49,20 @@ describe("createMobileFetch", () => { ], [ "another 410 code", - () => jsonResponse({ ...MOVED_BODY, code: "gone" }, 410), + () => Response.json({ ...MOVED_BODY, code: "gone" }, { status: 410 }), ], [ "missing details", - () => jsonResponse({ code: "server_moved", message: "moved" }, 410), + () => + Response.json( + { code: "server_moved", message: "moved" }, + { status: 410 }, + ), ], [ "a non-http address", () => - jsonResponse( + Response.json( { ...MOVED_BODY, details: { @@ -73,31 +70,31 @@ describe("createMobileFetch", () => { serverUrl: "javascript:alert(1)", }, }, - 410, + { status: 410 }, ), ], [ "an address without a scheme", () => - jsonResponse( + Response.json( { ...MOVED_BODY, details: { ...MOVED_BODY.details, serverUrl: "studio.local:38886" }, }, - 410, + { status: 410 }, ), ], [ "an empty host name", () => - jsonResponse( + Response.json( { ...MOVED_BODY, details: { ...MOVED_BODY.details, toHostName: "" } }, - 410, + { status: 410 }, ), ], [ "a server_moved body on another status", - () => jsonResponse(MOVED_BODY, 503), + () => Response.json(MOVED_BODY, { status: 503 }), ], ])("ignores %s", async (_label, response) => { const { mobileFetch, onServerMoved } = setup(response); @@ -110,7 +107,7 @@ describe("createMobileFetch", () => { it("still reports auth failures and tags the app surface", async () => { const { baseFetch, mobileFetch, onAuthFailure, onServerMoved } = setup(() => - jsonResponse({ code: "unauthorized" }, 401), + Response.json({ code: "unauthorized" }, { status: 401 }), ); await mobileFetch("https://bee.getbb.app/api/v1/x", { @@ -134,7 +131,7 @@ describe("profile client registry server moves", () => { const registry = createProfileClientRegistry({ onServerMoved, sdk: { - fetch: async () => jsonResponse(MOVED_BODY, 410), + fetch: async () => Response.json(MOVED_BODY, { status: 410 }), realtime: { socketFactory: createFakeSocketFactory(), onInvalidMessage: () => {}, diff --git a/apps/server/src/services/projects/attachments.test.ts b/apps/server/src/services/projects/attachments.test.ts index 5e1a1892658..180520b02a9 100644 --- a/apps/server/src/services/projects/attachments.test.ts +++ b/apps/server/src/services/projects/attachments.test.ts @@ -1,11 +1,10 @@ import type { PromptInput } from "@bb/domain"; import { getProjectAttachment, projects, type DbConnection } from "@bb/db"; import { createMigratedConnection } from "@bb/db/testing"; -import { beforeEach } from "vitest"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { makeTempDir } from "@bb/test-helpers"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { copyProjectAttachments, readAttachment, @@ -13,7 +12,6 @@ import { storeAttachment, } from "./attachments.js"; -const tempDirs: string[] = []; let db: DbConnection; beforeEach(() => { db = createMigratedConnection(); @@ -30,17 +28,8 @@ beforeEach(() => { .run(); }); -async function makeTempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), "bb-attachments-")); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { +afterEach(() => { db.$client.close(); - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), - ); }); describe("project attachments", () => { diff --git a/apps/server/test/desktop-browsers.test.ts b/apps/server/test/desktop-browsers.test.ts index a64f028cbb5..7b9de5bb427 100644 --- a/apps/server/test/desktop-browsers.test.ts +++ b/apps/server/test/desktop-browsers.test.ts @@ -23,19 +23,12 @@ import { seedThreadFixture, } from "./helpers/seed.js"; import { withTestHarness, type TestAppHarness } from "./helpers/test-app.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const leaseSchema = desktopBrowserScopeSchema .extend(desktopBrowserLeaseSchema.shape) .extend({ tabIds: z.array(z.string()) }); -function deferred() { - let resolve: (value: T) => void = () => {}; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - function fixture(harness: TestAppHarness) { const seeded = seedThreadFixture(harness); const scope = { @@ -741,8 +734,8 @@ describe("desktop browser public API", () => { (await test.post("connection", test.leaseRequest(lease.leaseId))) .status, ).toBe(409); - const gate = deferred(); - const started = deferred(); + const gate = createDeferredPromise(); + const started = createDeferredPromise(); test.intercept(({ command }) => { if (command.type !== "desktop.browser.acquire_control") return null; started.resolve(); @@ -785,8 +778,8 @@ describe("desktop browser public API", () => { it("a stop during the tab lookup cancels the pending acquisition before any native grant", async () => { await withBrowserTest(async (test) => { - const gate = deferred(); - const started = deferred(); + const gate = createDeferredPromise(); + const started = createDeferredPromise(); test.intercept(({ command }) => { if (command.type !== "desktop.browser.list_tabs") return null; started.resolve(); @@ -887,8 +880,8 @@ describe("desktop browser public API", () => { }); it("revokes native control when acquisition completes after its deadline", async () => { await withBrowserTest(async (test) => { - const gate = deferred(); - const started = deferred(); + const gate = createDeferredPromise(); + const started = createDeferredPromise(); test.intercept(({ command }) => { if (command.type !== "desktop.browser.acquire_control") return null; started.resolve(); @@ -948,7 +941,7 @@ describe("desktop browser public API", () => { await withBrowserTest(async (test) => { const tab = test.tab(); test.change({ instanceId: "closed-window" }, [tab]); - const listed = deferred(); + const listed = createDeferredPromise(); const list = vi.fn(() => listed.promise); test.intercept(({ command }) => command.type === "desktop.browser.list_instances" ? list() : null, @@ -1065,7 +1058,7 @@ describe("desktop browser public API", () => { await withBrowserTest(async (test) => { const tab = test.tab(); test.change({ instanceId: "closed-window" }, [tab]); - const listed = deferred(); + const listed = createDeferredPromise(); const list = vi.fn(() => listed.promise); const closed = vi.fn(); test.intercept(({ command }) => { diff --git a/apps/server/test/machine-auth.test.ts b/apps/server/test/machine-auth.test.ts index 1435de82f41..af62c9ecd39 100644 --- a/apps/server/test/machine-auth.test.ts +++ b/apps/server/test/machine-auth.test.ts @@ -1,14 +1,10 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { eq } from "drizzle-orm"; import { authApiKeys, authUsers } from "@bb/db"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMigratedConnection } from "@bb/db/testing"; import { createMachineAuthService } from "../src/services/machine-auth.js"; -const tempDirs: string[] = []; - const testLogger = { debug(): void {}, error(): void {}, @@ -16,12 +12,6 @@ const testLogger = { warn(): void {}, }; -async function makeTempDir(): Promise { - const dataDir = await mkdtemp(join(tmpdir(), "bb-machine-auth-")); - tempDirs.push(dataDir); - return dataDir; -} - async function createMachineAuthHarness() { const dataDir = await makeTempDir(); const db = createMigratedConnection(); @@ -38,11 +28,8 @@ async function createMachineAuthHarness() { }; } -afterEach(async () => { +afterEach(() => { vi.useRealTimers(); - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), - ); }); describe("machine auth service", () => { diff --git a/apps/server/test/routes/plugin-cli-response.test.ts b/apps/server/test/routes/plugin-cli-response.test.ts index 7d95d039335..646e6ea70cd 100644 --- a/apps/server/test/routes/plugin-cli-response.test.ts +++ b/apps/server/test/routes/plugin-cli-response.test.ts @@ -1,6 +1,7 @@ import type { PluginCliExecutionResult } from "@get-bb/plugin-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { pluginCliResponse } from "../../src/routes/plugins.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const RESULT: PluginCliExecutionResult = { exitCode: 0, @@ -8,16 +9,6 @@ const RESULT: PluginCliExecutionResult = { stderr: "", }; -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - describe("pluginCliResponse", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); @@ -30,7 +21,7 @@ describe("pluginCliResponse", () => { }); it("sends headers and keepalive bytes before a slow command settles", async () => { - const pending = deferred(); + const pending = createDeferredPromise(); const responsePromise = pluginCliResponse(pending.promise, 5); await vi.advanceTimersByTimeAsync(5); const response = await responsePromise; @@ -59,7 +50,7 @@ describe("pluginCliResponse", () => { }); it("reports a command that rejects after streaming began as a failed exit", async () => { - const pending = deferred(); + const pending = createDeferredPromise(); const responsePromise = pluginCliResponse(pending.promise, 5); await vi.advanceTimersByTimeAsync(5); const response = await responsePromise; @@ -73,7 +64,7 @@ describe("pluginCliResponse", () => { }); it("stops writing once the client goes away", async () => { - const pending = deferred(); + const pending = createDeferredPromise(); const responsePromise = pluginCliResponse(pending.promise, 5); await vi.advanceTimersByTimeAsync(5); const response = await responsePromise; diff --git a/apps/server/test/server-move/export.test.ts b/apps/server/test/server-move/export.test.ts index 1b347d0c517..daa1982944f 100644 --- a/apps/server/test/server-move/export.test.ts +++ b/apps/server/test/server-move/export.test.ts @@ -1,14 +1,6 @@ import Database from "better-sqlite3"; import { existsSync } from "node:fs"; -import { - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { createConnection, getHost, setExperiments } from "@bb/db"; import { defaultExperiments } from "@bb/domain"; @@ -17,19 +9,12 @@ import { extractServerArchive, serverMovedFileSchema, } from "@bb/server-archive"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { exportServerArchive } from "../../src/services/server-move/export.js"; import { seedHost } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), "bb-server-move-export-")); - tempDirs.push(dir); - return dir; -} - async function writeDataFile( dataDir: string, relativePath: string, @@ -40,11 +25,8 @@ async function writeDataFile( await writeFile(path, content); } -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), - ); }); describe("server archive export", () => { diff --git a/apps/server/test/server-move/routes.test.ts b/apps/server/test/server-move/routes.test.ts index f2e228ad545..34705a995a6 100644 --- a/apps/server/test/server-move/routes.test.ts +++ b/apps/server/test/server-move/routes.test.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { HOST_DAEMON_PROTOCOL_VERSION, @@ -19,8 +18,8 @@ import { } from "@bb/server-contract"; import { setExperiments } from "@bb/db"; import { defaultExperiments } from "@bb/domain"; -import { createDeferredPromise } from "@bb/test-helpers"; -import { afterEach, describe, expect, it } from "vitest"; +import { createDeferredPromise, makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { readJson } from "../helpers/json.js"; import { inspectResult, @@ -39,25 +38,12 @@ const OLD = "host-old"; const NEW = "host-new"; const WORKER = "host-worker"; const DIRECT_URL = "https://desktop.example.test"; -const tempDirs: string[] = []; type PrepareCommand = Extract< HostDaemonRpcCommand, { type: "server_move.prepare" } >; -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), - ); -}); - -async function makeTempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), "bb-server-move-routes-")); - tempDirs.push(dir); - return dir; -} - function postJson( harness: TestAppHarness, path: string, diff --git a/apps/server/test/services/async-ttl-memo.test.ts b/apps/server/test/services/async-ttl-memo.test.ts index 52e412b95e2..dc5fb35c454 100644 --- a/apps/server/test/services/async-ttl-memo.test.ts +++ b/apps/server/test/services/async-ttl-memo.test.ts @@ -1,15 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createAsyncTtlMemo } from "../../src/services/lib/async-ttl-memo.js"; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} +import { createDeferredPromise } from "@bb/test-helpers"; describe("createAsyncTtlMemo", () => { it("starts TTL at completion and shares cold and refresh bursts", async () => { @@ -18,7 +9,7 @@ describe("createAsyncTtlMemo", () => { ttlMs: 100, now: () => now, }); - const first = deferred(); + const first = createDeferredPromise(); const load = vi.fn(() => first.promise); const readers = Array.from({ length: 10 }, () => cache.run("key", load)); now = 500; @@ -27,7 +18,7 @@ describe("createAsyncTtlMemo", () => { now = 599; expect(await cache.run("key", load)).toBe("old"); expect(load).toHaveBeenCalledTimes(1); - const next = deferred(); + const next = createDeferredPromise(); load.mockImplementation(() => next.promise); const refreshes = Array.from({ length: 10 }, () => cache.run("key", load, true), @@ -36,7 +27,7 @@ describe("createAsyncTtlMemo", () => { next.resolve("new"); expect(await Promise.all(refreshes)).toEqual(Array(10).fill("new")); now = 699; - const expired = deferred(); + const expired = createDeferredPromise(); load.mockImplementation(() => expired.promise); let completed = false; const waiting = cache.run("key", load).then((value) => { @@ -62,8 +53,8 @@ describe("createAsyncTtlMemo", () => { ttlMs: 100, now: () => 0, }); - const stale = deferred(); - const fresh = deferred(); + const stale = createDeferredPromise(); + const fresh = createDeferredPromise(); const before = cache.run("key", () => stale.promise); if (operation === "clear") cache.clear(); else cache.invalidateWhere((key) => key === "key"); @@ -95,14 +86,14 @@ describe("createAsyncTtlMemo", () => { }); await cache.run("key", async () => "old"); now = 100; - const failed = deferred(); + const failed = createDeferredPromise(); const load = vi.fn(() => failed.promise); expect(await cache.run("key", load)).toBe("old"); expect(await cache.run("key", load)).toBe("old"); expect(load).toHaveBeenCalledTimes(1); failed.reject(new Error("offline")); await new Promise((resolve) => setTimeout(resolve, 0)); - const next = deferred(); + const next = createDeferredPromise(); load.mockImplementation(() => next.promise); expect(await cache.run("key", load)).toBe("old"); expect(load).toHaveBeenCalledTimes(2); @@ -111,7 +102,7 @@ describe("createAsyncTtlMemo", () => { expect(await cache.run("key", load)).toBe("new"), ); now = 300; - const forced = deferred(); + const forced = createDeferredPromise(); load.mockImplementation(() => forced.promise); const refreshed = cache.run("key", load, true); const waiting = cache.run("key", load); @@ -123,7 +114,7 @@ describe("createAsyncTtlMemo", () => { it("retries rejected and synchronously throwing lookups", async () => { const cache = createAsyncTtlMemo({ ttlMs: 100 }); - const failed = deferred(); + const failed = createDeferredPromise(); const first = cache.run("key", () => failed.promise); const second = cache.run("key", () => Promise.resolve("wrong")); failed.reject(new Error("offline")); diff --git a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts index 4a5ca72a650..5117b683018 100644 --- a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts +++ b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts @@ -94,16 +94,6 @@ function manifestV2( }; } -function jsonResponse( - body: unknown, - headers: Record = {}, -): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json", ...headers }, - }); -} - describe("plugin catalog service", () => { let db: DbConnection; let installedNames: string[]; @@ -414,7 +404,7 @@ describe("plugin catalog service", () => { return new Response(null, { status: 404 }); } if (url === V1_MANIFEST_URL) { - return jsonResponse(manifest([remoteEntry({ icon: "Zap" })])); + return Response.json(manifest([remoteEntry({ icon: "Zap" })])); } return new Response(null, { status: 404 }); }, @@ -436,7 +426,7 @@ describe("plugin catalog service", () => { requests.push(url); if (url === V2_MANIFEST_URL) { return v2Available - ? jsonResponse( + ? Response.json( manifestV2([ remoteEntry({ icon: "Zap", category: "security" }), ]), @@ -444,7 +434,7 @@ describe("plugin catalog service", () => { : new Response(null, { status: 404 }); } if (url === V1_MANIFEST_URL) { - return jsonResponse(manifest([remoteEntry({ icon: "Zap" })])); + return Response.json(manifest([remoteEntry({ icon: "Zap" })])); } return new Response(null, { status: 404 }); }, @@ -495,7 +485,7 @@ describe("plugin catalog service", () => { marketplaceUrl: CUSTOM_V1_MANIFEST_URL, fetch: async (url) => { requests.push(url); - return jsonResponse(manifest([remoteEntry({ icon: "Zap" })])); + return Response.json(manifest([remoteEntry({ icon: "Zap" })])); }, }); @@ -509,7 +499,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse( + ? Response.json( manifestV2( [ remoteEntry({ @@ -674,7 +664,7 @@ describe("plugin catalog service", () => { ]); const fetchImpl: MarketplaceFetch = async (url) => { if (url === MANIFEST_URL) { - return jsonResponse( + return Response.json( manifest([ remoteEntry({ id: "raster", @@ -716,7 +706,9 @@ describe("plugin catalog service", () => { if (url === MANIFEST_URL) { return requests.filter((request) => request.url === MANIFEST_URL) .length === 1 - ? jsonResponse(manifest([remoteEntry()]), { etag: '"v1"' }) + ? Response.json(manifest([remoteEntry()]), { + headers: { etag: '"v1"' }, + }) : new Response(null, { status: 304 }); } return new Response(VALID_SVG, { @@ -770,7 +762,7 @@ describe("plugin catalog service", () => { it("keeps the last-known-good catalog when the payload is invalid", async () => { const catalog = service({ fetch: async () => - jsonResponse(manifest([remoteEntry({ id: "Not Valid" })])), + Response.json(manifest([remoteEntry({ id: "Not Valid" })])), }); await expect(refreshCuratedMarketplace(catalog, 5_000)).rejects.toThrow( /invalid marketplace manifest/, @@ -803,7 +795,7 @@ describe("plugin catalog service", () => { it("refuses a manifest published under another marketplace name", async () => { const catalog = service({ fetch: async () => - jsonResponse({ + Response.json({ ...(manifest([remoteEntry()]) as Record), name: "someone-else", }), @@ -835,7 +827,7 @@ describe("plugin catalog service", () => { warn: (message) => warnings.push(message), fetch: async (url) => url === MANIFEST_URL - ? jsonResponse(manifest([remoteEntry()])) + ? Response.json(manifest([remoteEntry()])) : new Response(Buffer.from("not an icon"), { status: 200, }), @@ -855,7 +847,7 @@ describe("plugin catalog service", () => { warn: (message) => warnings.push(message), fetch: async (url) => url === MANIFEST_URL - ? jsonResponse(manifest([remoteEntry()])) + ? Response.json(manifest([remoteEntry()])) : new Response(Buffer.alloc(300 * 1024, 0x41), { status: 200 }), }); await refreshCuratedMarketplace(catalog, 1_000); @@ -873,7 +865,7 @@ describe("plugin catalog service", () => { fetch: async (url) => { if (url === MANIFEST_URL) { return iconRequests === 0 - ? jsonResponse( + ? Response.json( manifest([ remoteEntry(), remoteEntry({ @@ -881,7 +873,7 @@ describe("plugin catalog service", () => { icon: { url: "./icons/gadgets.svg" }, }), ]), - { etag: '"v1"' }, + { headers: { etag: '"v1"' } }, ) : new Response(null, { status: 304 }); } @@ -907,7 +899,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse( + ? Response.json( manifest([ listIcon ? remoteEntry() : remoteEntry({ icon: "Zap" }), ]), @@ -927,7 +919,7 @@ describe("plugin catalog service", () => { warn: () => {}, fetch: async (url) => { if (url === MANIFEST_URL) { - return jsonResponse( + return Response.json( manifest([remoteEntry({ icon: { url: iconUrl } })]), ); } @@ -955,7 +947,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse(manifest([remoteEntry()])) + ? Response.json(manifest([remoteEntry()])) : new Response(VALID_SVG, { status: 200 }), }); @@ -977,7 +969,7 @@ describe("plugin catalog service", () => { plugins: Record, generatedAt = "2026-08-21T00:00:00.000Z", ): Response { - return jsonResponse({ schemaVersion: 1, generatedAt, plugins }); + return Response.json({ schemaVersion: 1, generatedAt, plugins }); } function fetchWith( @@ -985,7 +977,7 @@ describe("plugin catalog service", () => { entries: unknown[] = [remoteEntry()], ): MarketplaceFetch { return async (url) => { - if (url === MANIFEST_URL) return jsonResponse(manifest(entries)); + if (url === MANIFEST_URL) return Response.json(manifest(entries)); if (url === STATS_URL) return stats(); return new Response(VALID_SVG, { status: 200, @@ -1034,7 +1026,9 @@ describe("plugin catalog service", () => { if (url === MANIFEST_URL) { manifestReads += 1; return manifestReads === 1 - ? jsonResponse(manifest([remoteEntry()]), { etag: '"v1"' }) + ? Response.json(manifest([remoteEntry()]), { + headers: { etag: '"v1"' }, + }) : new Response(null, { status: 304 }); } if (url === STATS_URL) { @@ -1080,7 +1074,7 @@ describe("plugin catalog service", () => { it("rejects a malformed sidecar whole rather than counting part of it", async () => { const catalog = service({ fetch: fetchWith(() => - jsonResponse({ + Response.json({ schemaVersion: 1, generatedAt: "2026-08-21T00:00:00.000Z", plugins: { widgets: { installs: -1 } }, @@ -1098,13 +1092,13 @@ describe("plugin catalog service", () => { const statsRequests: string[] = []; const catalog = service({ fetch: async (url) => { - if (url === MANIFEST_URL) return jsonResponse(manifest([])); + if (url === MANIFEST_URL) return Response.json(manifest([])); if (url.endsWith("/stats.json")) { statsRequests.push(url); return statsResponse({ widgets: { installs: 999 } }); } if (url === thirdPartyManifest) { - return jsonResponse({ + return Response.json({ schemaVersion: 1, name: "acme", displayName: "Acme", @@ -1130,7 +1124,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse(manifest([entry])) + ? Response.json(manifest([entry])) : new Response(VALID_SVG, { status: 200 }), }); await refreshCuratedMarketplace(catalog, 1_000); @@ -1319,7 +1313,7 @@ describe("plugin catalog service", () => { ), ); const catalog = service({ - fetch: async () => jsonResponse(largeManifest), + fetch: async () => Response.json(largeManifest), }); await refreshCuratedMarketplace(catalog, 1_000); @@ -1342,7 +1336,7 @@ describe("plugin catalog service", () => { ); expect(JSON.stringify(largeManifest).length).toBeGreaterThan(1_048_576); const catalog = service({ - fetch: async () => jsonResponse(largeManifest), + fetch: async () => Response.json(largeManifest), }); await refreshCuratedMarketplace(catalog, 1_000); @@ -1363,7 +1357,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse(manifest(entries)) + ? Response.json(manifest(entries)) : new Response(bigSvg, { status: 200, headers: { "content-type": "image/svg+xml" }, @@ -1389,7 +1383,7 @@ describe("plugin catalog service", () => { ); const catalog = service({ fetch: async (url) => { - if (url === MANIFEST_URL) return jsonResponse(manifest(entries)); + if (url === MANIFEST_URL) return Response.json(manifest(entries)); inFlight += 1; peak = Math.max(peak, inFlight); await new Promise((resolve) => setTimeout(resolve, 5)); @@ -1413,7 +1407,7 @@ describe("plugin catalog service", () => { warn: (message) => warnings.push(message), fetch: async (url) => { if (url === MANIFEST_URL) { - return jsonResponse( + return Response.json( manifest([ remoteEntry({ icon: { url: "https://127.0.0.1/widgets.svg" } }), ]), @@ -1441,7 +1435,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse( + ? Response.json( manifest([ remoteEntry({ id: occupied.pluginId }), remoteEntry({ id: "widgets" }), @@ -1471,7 +1465,7 @@ describe("plugin catalog service", () => { const catalog = service({ fetch: async (url) => url === MANIFEST_URL - ? jsonResponse( + ? Response.json( manifest([ remoteEntry({ icon: "ZoomIn", diff --git a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts index 84b60d864e9..bf8dd1d1835 100644 --- a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts +++ b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts @@ -66,13 +66,6 @@ function manifest( }; } -function jsonResponse(body: unknown, headers: Record = {}) { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json", ...headers }, - }); -} - function svgResponse(bytes: Buffer) { return new Response(new Uint8Array(bytes), { status: 200, @@ -142,7 +135,7 @@ describe("third-party marketplaces", () => { ): MarketplaceFetch { return async (url) => { const document = documents[url]; - if (document !== undefined) return jsonResponse(document); + if (document !== undefined) return Response.json(document); const icon = icons[url]; if (icon !== undefined) return svgResponse(icon); return new Response("not found", { status: 404 }); @@ -725,7 +718,7 @@ describe("third-party marketplaces", () => { const catalog = service({ fetch: async (url) => url === ACME_URL - ? jsonResponse( + ? Response.json( manifest("acme-plugins", [ entry({ source: { npm: { package: packageName } } }), ]), @@ -865,13 +858,13 @@ describe("third-party marketplaces", () => { warn: (message) => warnings.push(message), fetch: async (url) => { if (url === OFFICIAL_URL) { - return jsonResponse( + return Response.json( manifest("bb-community", [entry({ id: "official-notes" })]), ); } if (url === ACME_URL) { if (acmeFails) return new Response("boom", { status: 503 }); - return jsonResponse(manifest("acme-plugins", [entry()])); + return Response.json(manifest("acme-plugins", [entry()])); } return new Response("not found", { status: 404 }); }, diff --git a/apps/server/test/services/plugins/plugin-install-jobs.test.ts b/apps/server/test/services/plugins/plugin-install-jobs.test.ts index ff465b63ae7..d1c33416f04 100644 --- a/apps/server/test/services/plugins/plugin-install-jobs.test.ts +++ b/apps/server/test/services/plugins/plugin-install-jobs.test.ts @@ -16,6 +16,7 @@ import { withTestHarness, type TestAppHarness, } from "../../helpers/test-app.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const BASE = "http://127.0.0.1:3334"; let PLUGIN: InstalledPlugin; @@ -26,16 +27,6 @@ beforeAll(async () => { ); }); -function deferred() { - let resolve: (value: T) => void = () => {}; - let reject: (error: unknown) => void = () => {}; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - function sourceTarget(source: string) { return { kind: "source" as const, @@ -47,7 +38,7 @@ function sourceTarget(source: string) { describe("plugin install jobs", () => { it("runs installs one at a time and folds a repeat request into the active job", async () => { const jobs = createPluginInstallJobs({ notifyChanged: () => {} }); - const first = deferred(); + const first = createDeferredPromise(); const second = vi.fn(async () => PLUGIN); const a = jobs.start({ @@ -80,7 +71,7 @@ describe("plugin install jobs", () => { it("drops a cancelled queued install without starting it", async () => { const jobs = createPluginInstallJobs({ notifyChanged: () => {} }); - const first = deferred(); + const first = createDeferredPromise(); const queuedRun = vi.fn(async () => PLUGIN); jobs.start({ target: sourceTarget("npm:a"), @@ -108,7 +99,7 @@ describe("plugin install jobs", () => { "reports a cancelled running install as $expected when it $outcome", async ({ outcome, expected }) => { const jobs = createPluginInstallJobs({ notifyChanged: () => {} }); - const run = deferred(); + const run = createDeferredPromise(); const job = jobs.start({ target: sourceTarget("npm:a"), displayName: "a", diff --git a/apps/server/test/services/plugins/plugin-tool-calls.test.ts b/apps/server/test/services/plugins/plugin-tool-calls.test.ts index 57b2076e741..4d0bc2ff4f8 100644 --- a/apps/server/test/services/plugins/plugin-tool-calls.test.ts +++ b/apps/server/test/services/plugins/plugin-tool-calls.test.ts @@ -6,21 +6,12 @@ import { detachActivePluginToolCallForUserInput, } from "../../../src/services/plugins/plugin-tool-calls.js"; import { testLogger } from "../../helpers/test-app.js"; +import { createDeferredPromise } from "@bb/test-helpers"; function textResponse(text: string, success = true): ToolCallResponse { return { success, contentItems: [{ type: "inputText", text }] }; } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - describe("PluginToolCallRegistry", () => { beforeEach(() => { vi.useFakeTimers(); @@ -37,7 +28,7 @@ describe("PluginToolCallRegistry", () => { const registry = createRegistry(); const roundTrip = new AbortController(); const onDetachedResult = vi.fn(async () => undefined); - const result = deferred(); + const result = createDeferredPromise(); const response = registry.run({ pluginId: "fixture", @@ -59,7 +50,7 @@ describe("PluginToolCallRegistry", () => { const registry = createRegistry(); const roundTrip = new AbortController(); const onDetachedResult = vi.fn(async () => undefined); - const result = deferred(); + const result = createDeferredPromise(); let toolSignal: AbortSignal | undefined; const response = registry.run({ pluginId: "fixture", @@ -107,7 +98,7 @@ describe("PluginToolCallRegistry", () => { it("keeps a slow tool on its round trip beyond the former deadline", async () => { const registry = createRegistry(); const onDetachedResult = vi.fn(async () => undefined); - const result = deferred(); + const result = createDeferredPromise(); const response = registry.run({ pluginId: "fixture", threadId: "thread", @@ -132,7 +123,7 @@ describe("PluginToolCallRegistry", () => { const registry = createRegistry(); const roundTrip = new AbortController(); const onDetachedResult = vi.fn(async () => undefined); - const result = deferred(); + const result = createDeferredPromise(); let toolSignal: AbortSignal | undefined; const response = registry.run({ @@ -172,7 +163,7 @@ describe("PluginToolCallRegistry", () => { const registry = createRegistry(); const roundTrip = new AbortController(); const onDetachedResult = vi.fn(async () => undefined); - const result = deferred(); + const result = createDeferredPromise(); let toolSignal: AbortSignal | undefined; void registry.run({ diff --git a/apps/server/test/skills/builtin-skills-copy.test.ts b/apps/server/test/skills/builtin-skills-copy.test.ts index 696712cffce..bfd2d3b4476 100644 --- a/apps/server/test/skills/builtin-skills-copy.test.ts +++ b/apps/server/test/skills/builtin-skills-copy.test.ts @@ -1,7 +1,7 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { BUILTIN_SKILLS_DIRECTORY_NAME, copyBuiltinSkills, @@ -9,14 +9,6 @@ import { resolveBuiltinSkillsRootPathForModuleDir, } from "../../src/services/skills/builtin-skills-copy.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const dir = await mkdtemp(path.join(tmpdir(), "bb-builtin-skills-copy-")); - tempDirs.push(dir); - return dir; -} - async function readBuiltinSkill(skillName: string): Promise { return readFile( path.join(resolveBuiltinSkillsRootPath(), skillName, "SKILL.md"), @@ -24,12 +16,6 @@ async function readBuiltinSkill(skillName: string): Promise { ); } -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), - ); -}); - describe("builtin skills copy", () => { it("copies the bundled skills so the dist layout resolves beside the module", async () => { const moduleDir = await makeTempDir(); diff --git a/apps/server/test/skills/injected-skills.test.ts b/apps/server/test/skills/injected-skills.test.ts index e74fbb53528..905c9447bb3 100644 --- a/apps/server/test/skills/injected-skills.test.ts +++ b/apps/server/test/skills/injected-skills.test.ts @@ -2,16 +2,14 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, - mkdtemp, readFile, rename, - rm, symlink, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { resolveBuiltinSkillsRootPath } from "../../src/services/skills/builtin-skills-copy.js"; import { hashSkillTreeEntries, @@ -52,20 +50,6 @@ function resolveInjectedSkillSources( }).map((entry) => entry.runtimeSource); } -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const root = await mkdtemp(path.join(tmpdir(), "bb-injected-skills-")); - tempDirs.push(root); - return root; -} - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), - ); -}); - function createCapturingLogger(): CapturingLogger { const debugs: CapturedLog[] = []; const infos: CapturedLog[] = []; diff --git a/apps/server/test/skills/submit-a-plugin.test.ts b/apps/server/test/skills/submit-a-plugin.test.ts index 4f3e0c2792e..4a67f7273bc 100644 --- a/apps/server/test/skills/submit-a-plugin.test.ts +++ b/apps/server/test/skills/submit-a-plugin.test.ts @@ -1,11 +1,11 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { derivePluginId } from "@bb/domain"; +import { makeTempDir } from "@bb/test-helpers"; const skillRoot = fileURLToPath( new URL( @@ -24,7 +24,6 @@ const skillReferencePaths = [ "plugin-release.md", "pull-request.md", ].map((name) => path.join(skillRoot, "references", name)); -const tempDirs: string[] = []; async function readSkillTree(): Promise { return ( @@ -34,12 +33,6 @@ async function readSkillTree(): Promise { ).join("\n"); } -async function makeTempDir(): Promise { - const directory = await mkdtemp(path.join(tmpdir(), "bb-submit-plugin-")); - tempDirs.push(directory); - return directory; -} - async function deriveWithSkill(packageName: string): Promise { const directory = await makeTempDir(); const manifestPath = path.join(directory, "package.json"); @@ -49,14 +42,6 @@ async function deriveWithSkill(packageName: string): Promise { }).trim(); } -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - describe("submit-a-plugin skill", () => { it("derives dotted and underscored package ids with the product algorithm", async () => { for (const packageName of [ diff --git a/packages/host-watcher/test/watch-path.test.ts b/packages/host-watcher/test/watch-path.test.ts index e1db1251a7a..c2ce9e23224 100644 --- a/packages/host-watcher/test/watch-path.test.ts +++ b/packages/host-watcher/test/watch-path.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import parcelWatcher from "@parcel/watcher"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as pathExistsModule from "../src/path-exists.js"; import { watchPathChanges as watchPathChangesImpl } from "../src/watch-path.js"; @@ -30,7 +31,6 @@ interface ImportWatchPathOptions { type Sleep = (durationMs: number) => Promise; -const tempDirs: string[] = []; const sleep: Sleep = (durationMs) => new Promise((resolve) => { setTimeout(() => { @@ -44,12 +44,6 @@ async function settleAsyncWatchWork(): Promise { await Promise.resolve(); } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function waitFor( getValue: () => T, predicate: (value: T) => boolean, @@ -121,11 +115,6 @@ function createEventBatch(paths: string[]): ParcelWatcherEventBatch { afterEach(async () => { await settleAsyncWatchWork(); vi.restoreAllMocks(); - await Promise.all( - tempDirs.splice(0).map(async (dir) => { - await fs.rm(dir, { force: true, recursive: true }); - }), - ); }); describe.sequential("watchPathChanges", () => { diff --git a/packages/host-watcher/test/watch-status.test.ts b/packages/host-watcher/test/watch-status.test.ts index 7955ffc4e46..09174f286b9 100644 --- a/packages/host-watcher/test/watch-status.test.ts +++ b/packages/host-watcher/test/watch-status.test.ts @@ -1,12 +1,11 @@ import { execFile } from "node:child_process"; import fsSync from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import parcelWatcher from "@parcel/watcher"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createDeferredPromise } from "@bb/test-helpers"; +import { createDeferredPromise, initRepo, makeTempDir } from "@bb/test-helpers"; import { watchWorkspaceStatus as watchWorkspaceStatusImpl } from "../src/watch-status.js"; type WatchWorkspaceStatus = typeof watchWorkspaceStatusImpl; @@ -52,7 +51,6 @@ interface ManualWorkspaceEventsImport extends WatchWorkspaceStatusImport { type Sleep = (durationMs: number) => Promise; const execFileAsync = promisify(execFile); -const tempDirs: string[] = []; const WATCH_READY_SETTLE_MS = 100; const WATCH_TEST_TIMEOUT_MS = 2_000; const FSEVENTS_DROPPED_EVENTS_ERROR_MESSAGE = @@ -81,28 +79,10 @@ async function runGit( }); } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return fs.realpath(dir); -} - -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-workspace-repo-"); - await runGit({ args: ["init", "-b", "main"], cwd: repoPath }); - await runGit({ args: ["config", "user.name", "BB Tests"], cwd: repoPath }); - await runGit({ - args: ["config", "user.email", "bb@example.com"], - cwd: repoPath, - }); - await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); - await runGit({ args: ["add", "README.md"], cwd: repoPath }); - await runGit({ args: ["commit", "-m", "Initial commit"], cwd: repoPath }); - return repoPath; -} - async function addDetachedWorktree(repoPath: string): Promise { - const worktreeParent = await makeTempDir("bb-workspace-worktree-"); + const worktreeParent = await fs.realpath( + await makeTempDir("bb-workspace-worktree-"), + ); const worktreePath = path.join(worktreeParent, "env"); await runGit({ args: ["worktree", "add", "--detach", worktreePath, "HEAD"], @@ -113,7 +93,9 @@ async function addDetachedWorktree(repoPath: string): Promise { async function replaceDotGitWithSymlink(repoPath: string): Promise { const originalDotGitPath = path.join(repoPath, ".git"); - const gitDirParent = await makeTempDir("bb-workspace-gitdir-"); + const gitDirParent = await fs.realpath( + await makeTempDir("bb-workspace-gitdir-"), + ); const movedGitDirPath = path.join(gitDirParent, "gitdir"); await fs.rename(originalDotGitPath, movedGitDirPath); await fs.symlink(movedGitDirPath, originalDotGitPath, "dir"); @@ -382,16 +364,13 @@ async function importWatchWorkspaceStatusWithManualRootEvents( afterEach(async () => { await settleAsyncWatchWork(); vi.restoreAllMocks(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); describe.sequential("watchWorkspaceStatus", () => { it("starts watching before git init and promotes the repository watch", async () => { - const workspacePath = await makeTempDir("bb-workspace-plain-"); + const workspacePath = await fs.realpath( + await makeTempDir("bb-workspace-plain-"), + ); const workspaceRootCallbacks: ParcelWatcherCallback[] = []; const workspaceRootOptions: ParcelWatcherSubscribeOptions[] = []; const ready = createDeferredPromise(); @@ -448,7 +427,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("watches workspace status changes without duplicate callbacks for the same burst", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); const calls: number[] = []; @@ -484,7 +465,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("flushes sustained workspace event bursts at max wait", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); const calls: number[] = []; @@ -525,7 +508,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("reports change callback failures and continues watching", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); let callbackCount = 0; @@ -573,7 +558,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("rescans after dropped FSEvents errors and after watcher recovery", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootError, getWorkspaceRootSubscriptionCount, @@ -624,7 +611,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("does not retry workspace subscriptions after Parcel inotify poll interruptions", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootError, getWorkspaceRootSubscriptionCount, @@ -663,7 +652,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("does not emit callbacks when a clean workspace watch starts", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); const calls: number[] = []; @@ -683,7 +674,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("uses Git ignored directories for workspace root subscription ignores", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); await fs.writeFile( path.join(repoPath, ".gitignore"), "node_modules/\n.turbo/\ncoverage/\n", @@ -742,7 +735,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("falls back to a live root watch when Git ignored directory discovery fails", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); await fs.rm(path.join(repoPath, ".git"), { force: true, recursive: true }); await fs.writeFile( path.join(repoPath, ".git"), @@ -799,7 +794,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("keeps workspace root watching when git metadata watch resolution fails", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); await replaceDotGitWithSymlink(repoPath); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); @@ -826,7 +823,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("detects branch-ref updates through metadata watches", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const gitDirPath = normalizeWatchPath( path.resolve( repoPath, @@ -874,7 +873,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("retries workspace subscriptions when setup fails", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithTransientWorkspaceSubscriptionFailure( repoPath, @@ -912,7 +913,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("reports persistent workspace watch startup failures once while retries continue", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { getWorkspaceSubscriptionAttemptCount, ready, @@ -954,7 +957,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("settles stop and cleans up a late workspace subscription", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const rootPaths: string[] = []; const subscriptionDeferred = createDeferredPromise(); @@ -998,7 +1003,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("ignores shared common-dir index updates for detached worktree environments", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const worktreePath = await addDetachedWorktree(repoPath); const expectedRootPaths = await resolveExpectedWatchRootPaths(worktreePath); const commonDirPath = path.resolve( @@ -1040,7 +1047,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("detects namespaced branch ref updates for detached worktree environments", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const initialHead = ( await runGit({ args: ["rev-parse", "HEAD"], cwd: repoPath }) ).stdout.trim(); @@ -1110,7 +1119,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("classifies direct checkout ref updates as shared git ref changes", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const gitDirPath = normalizeWatchPath( path.resolve( repoPath, @@ -1158,7 +1169,9 @@ describe.sequential("watchWorkspaceStatus", () => { }); it("stops emitting callbacks after watch teardown", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo( + await fs.realpath(await makeTempDir("bb-workspace-repo-")), + ); const { emitWorkspaceRootEvents, ready, watchWorkspaceStatus } = await importWatchWorkspaceStatusWithManualWorkspaceEvents(repoPath); const calls: number[] = []; diff --git a/packages/host-watcher/test/workspace-root-ignores.test.ts b/packages/host-watcher/test/workspace-root-ignores.test.ts index 5bafb948e84..02db64f648b 100644 --- a/packages/host-watcher/test/workspace-root-ignores.test.ts +++ b/packages/host-watcher/test/workspace-root-ignores.test.ts @@ -1,17 +1,15 @@ import { execFile } from "node:child_process"; import fsSync from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import parcelWatcher from "@parcel/watcher"; -import { createDeferredPromise } from "@bb/test-helpers"; +import { createDeferredPromise, initRepo, makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { watchWorkspaceStatus } from "../src/watch-status.js"; import type { WorkspaceStatusChangeEvent } from "../src/watch-status-types.js"; const execFileAsync = promisify(execFile); -const tempDirs: string[] = []; const realParcelSubscribe = parcelWatcher.subscribe.bind(parcelWatcher); const NESTED_REPOS = 4; @@ -23,16 +21,6 @@ async function git(cwd: string, ...args: string[]): Promise { await execFileAsync("git", args, { cwd, encoding: "utf8" }); } -async function initRepo(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }); - await git(dir, "init", "-q", "-b", "main"); - await git(dir, "config", "user.name", "BB Tests"); - await git(dir, "config", "user.email", "bb@example.com"); - await fs.writeFile(path.join(dir, "README.md"), "hello\n"); - await git(dir, "add", "README.md"); - await git(dir, "commit", "-q", "-m", "init"); -} - async function buildUmbrellaRoot(args: { gitRoot: boolean; nestedRepos?: number; @@ -41,8 +29,7 @@ async function buildUmbrellaRoot(args: { root: string; nestedDirCount: number; }> { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-1779-umbrella-")); - tempDirs.push(root); + const root = await makeTempDir("bb-1779-umbrella-"); if (args.gitRoot) { await initRepo(root); } @@ -52,6 +39,7 @@ async function buildUmbrellaRoot(args: { let nestedDirCount = 0; for (let i = 0; i < nestedRepos; i += 1) { const child = path.join(root, "apps", `child-${i}`); + await fs.mkdir(child, { recursive: true }); await initRepo(child); await fs.writeFile(path.join(child, ".gitignore"), "node_modules/\n"); await git(child, "add", ".gitignore"); @@ -122,11 +110,8 @@ async function measureWorkspaceRootWatch(root: string): Promise<{ } } -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); - for (const dir of tempDirs.splice(0)) { - await fs.rm(dir, { force: true, recursive: true }); - } }); describe.skipIf(process.platform !== "linux")( diff --git a/packages/host-workspace/test/git-fetch.test.ts b/packages/host-workspace/test/git-fetch.test.ts index cb43d21af95..3d2f3886966 100644 --- a/packages/host-workspace/test/git-fetch.test.ts +++ b/packages/host-workspace/test/git-fetch.test.ts @@ -1,59 +1,27 @@ import fs from "node:fs/promises"; import http from "node:http"; -import os from "node:os"; import path from "node:path"; +import { initRepo, makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchRemoteBranches, runGit } from "../src/git.js"; -const tempDirs: string[] = []; - function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -async function initRepo(): Promise { - const repo = await fs.mkdtemp(path.join(os.tmpdir(), "bb-git-fetch-")); - tempDirs.push(repo); - await runGit(["init", "-b", "main"], { cwd: repo }); - await runGit( - [ - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.com", - "commit", - "--allow-empty", - "-m", - "Initial commit", - ], - { cwd: repo }, - ); - return repo; -} - async function writeScript(repo: string, name: string, body: string) { const script = path.join(repo, name); await fs.writeFile(script, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); return script; } -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); }); describe("background Git authentication", () => { it("preserves a working GIT_SSH wrapper, including paths with spaces", async () => { - const repo = await initRepo(); + const repo = await initRepo(await makeTempDir("bb-git-fetch-")); const wrapper = await writeScript( repo, "ssh wrapper.sh", @@ -76,7 +44,7 @@ describe("background Git authentication", () => { it.each(["GIT_ASKPASS", "core.askPass", "SSH_ASKPASS"])( "suppresses %s on HTTP authentication while preserving explicit refresh prompts", async (setting) => { - const repo = await initRepo(); + const repo = await initRepo(await makeTempDir("bb-git-fetch-")); const log = path.join(repo, "prompts.log"); const askpass = await writeScript( repo, @@ -136,7 +104,7 @@ describe("background Git authentication", () => { it.skipIf(process.platform === "win32")( "kills background transport descendants when a refresh times out", async () => { - const repo = await initRepo(); + const repo = await initRepo(await makeTempDir("bb-git-fetch-")); const marker = path.join(repo, "late-side-effect"); const wrapper = await writeScript( repo, diff --git a/packages/host-workspace/test/git-host-upstream.test.ts b/packages/host-workspace/test/git-host-upstream.test.ts index bc99e85fd0d..d600971e3bd 100644 --- a/packages/host-workspace/test/git-host-upstream.test.ts +++ b/packages/host-workspace/test/git-host-upstream.test.ts @@ -1,14 +1,13 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runGit } from "../src/git.js"; import { Workspace } from "../src/workspace.js"; const execFileAsync = promisify(execFile); -const tempDirs: string[] = []; const localBranch = "bb/review-github-issue-1235-thr_test"; const forkRemote = "review-fork"; @@ -34,12 +33,6 @@ function pullRequestJson(): string { }); } -async function makeTempDir(prefix: string): Promise { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(directory); - return directory; -} - async function createTrackedForkWorkspace( remoteUrl = forkRemoteUrl, ): Promise { @@ -158,18 +151,8 @@ async function readGhCalls(logPath: string): Promise { } } -afterEach(async () => { +afterEach(() => { vi.unstubAllEnvs(); - await Promise.all( - tempDirs.splice(0).map((directory) => - fs.rm(directory, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); }); describe("pull request lookup for differently named upstream branches", () => { diff --git a/packages/host-workspace/test/git-ref-mutation-lock.test.ts b/packages/host-workspace/test/git-ref-mutation-lock.test.ts index 91c41f647c5..a81632242a0 100644 --- a/packages/host-workspace/test/git-ref-mutation-lock.test.ts +++ b/packages/host-workspace/test/git-ref-mutation-lock.test.ts @@ -1,21 +1,12 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createDeferredPromise } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; +import { createDeferredPromise, makeTempDir } from "@bb/test-helpers"; import { ProcessLocalQueuedLockTimeoutError, withGitRefMutationLock, } from "bb-environment-provider-host/process-local-lock"; -const tempDirs: string[] = []; - -async function makeTempDir(name: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), `bb-${name}-`)); - tempDirs.push(dir); - return dir; -} - function waitForLockContention(): Promise { return new Promise((resolve) => setTimeout(resolve, 100)); } @@ -46,29 +37,16 @@ async function expectPathsShareLock( expect(secondEntered).toBe(true); } -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); -}); - describe("git ref mutation lock", () => { it("serializes mutations for the same resolved common directory", async () => { - const commonDir = await makeTempDir("git-ref-lock"); + const commonDir = await makeTempDir("bb-git-ref-lock-"); await expectPathsShareLock(commonDir, `${commonDir}${path.sep}.`); }); it.runIf(process.platform !== "win32")( "serializes symbolic-link aliases of one common directory", async () => { - const parentDir = await makeTempDir("git-ref-lock-symlink"); + const parentDir = await makeTempDir("bb-git-ref-lock-symlink-"); const commonDir = path.join(parentDir, "common"); const aliasDir = path.join(parentDir, "alias"); await fs.mkdir(commonDir); @@ -98,7 +76,7 @@ describe("git ref mutation lock", () => { ); it("allows callers to bound how long they wait for the lock", async () => { - const commonDir = await makeTempDir("git-ref-lock-timeout"); + const commonDir = await makeTempDir("bb-git-ref-lock-timeout-"); const firstEntered = createDeferredPromise(); const releaseFirst = createDeferredPromise(); const first = withGitRefMutationLock(commonDir, async () => { @@ -122,7 +100,7 @@ describe("git ref mutation lock", () => { describe("withGitRefMutationLock across processes", () => { it("waits for a lock directory another process holds and takes over a stale one", async () => { - const commonDir = await fs.mkdtemp(path.join(os.tmpdir(), "bb-ref-lock-")); + const commonDir = await makeTempDir("bb-ref-lock-"); const lockPath = path.join(commonDir, "bb-ref-mutation.lock"); await fs.mkdir(lockPath); let entered = false; diff --git a/packages/host-workspace/test/provision.test.ts b/packages/host-workspace/test/provision.test.ts index c313d2da424..2f77e082fa6 100644 --- a/packages/host-workspace/test/provision.test.ts +++ b/packages/host-workspace/test/provision.test.ts @@ -1,46 +1,13 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { initRepo, makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { provisionWorkspace } from "../src/index.js"; import { runGit } from "../src/git.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-provision-repo-"); - await runGit(["init", "-b", "main"], { cwd: repoPath }); - await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); - await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); - await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); - await runGit(["add", "."], { cwd: repoPath }); - await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath }); - return repoPath; -} - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); -}); - describe("provisionWorkspace", () => { describe("unmanaged", () => { it("provisions an unmanaged git repo and discovers properties", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-provision-repo-")); const ws = await provisionWorkspace({ path: repoPath, @@ -64,7 +31,7 @@ describe("provisionWorkspace", () => { }); it("detects a worktree as isWorktree=true", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-provision-repo-")); const parentDir = await makeTempDir("bb-provision-wt-parent-"); const wtPath = path.join(parentDir, "wt"); await runGit(["worktree", "add", "-B", "feature", wtPath], { @@ -80,7 +47,7 @@ describe("provisionWorkspace", () => { }); it("resolves external git metadata roots for unmanaged worktrees", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-provision-repo-")); const parentDir = await makeTempDir("bb-provision-unmanaged-wt-roots-"); const wtPath = path.join(parentDir, "wt"); await runGit(["worktree", "add", "-B", "feature", wtPath], { diff --git a/packages/host-workspace/test/workspace-diff.test.ts b/packages/host-workspace/test/workspace-diff.test.ts index c2d36eef90d..d6c45b75282 100644 --- a/packages/host-workspace/test/workspace-diff.test.ts +++ b/packages/host-workspace/test/workspace-diff.test.ts @@ -1,28 +1,11 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { initRepo, makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import { Workspace } from "../src/workspace.js"; import { runGit } from "../src/git.js"; import type { RawDiffFileStat, WorkspaceDiffTarget } from "@bb/domain"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-diff-repo-"); - await runGit(["init", "-b", "main"], { cwd: repoPath }); - await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); - await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); - await runGit(["config", "core.autocrlf", "false"], { cwd: repoPath }); - return repoPath; -} - async function write( repoPath: string, relativePath: string, @@ -129,22 +112,11 @@ function formatDiffSection(lines: string[]): string { return `${lines.slice(0, end).join("\n")}\n`; } -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); -}); - describe("Workspace.diffFiles", () => { it("reports staged and untracked files before the initial commit", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "staged.txt", "staged pending\n"); await runGit(["add", "staged.txt"], { cwd: repoPath }); await write(repoPath, "untracked.txt", "untracked pending\n"); @@ -198,7 +170,9 @@ describe("Workspace.diffFiles", () => { }); it("reports additions, modifications, and deletions with numstat counts", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "keep.txt", "a\nb\nc\n"); await write(repoPath, "remove.txt", "x\ny\n"); await commitAll(repoPath, "base"); @@ -240,7 +214,9 @@ describe("Workspace.diffFiles", () => { }); it("detects renames with previousPath and copies", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "original.txt", "line1\nline2\nline3\nline4\n"); await commitAll(repoPath, "base"); @@ -259,7 +235,9 @@ describe("Workspace.diffFiles", () => { }); it("detects a type change (file to symlink) as statusLetter T", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "target.txt", "hello\n"); await write(repoPath, "thing", "i am a regular file\n"); await commitAll(repoPath, "base"); @@ -278,7 +256,9 @@ describe("Workspace.diffFiles", () => { }); it("marks binary files with binary:true and zero counts", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "readme.txt", "text\n"); await commitAll(repoPath, "base"); @@ -302,7 +282,9 @@ describe("Workspace.diffFiles", () => { }); it("includes untracked files tagged origin:untracked for uncommitted target", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "tracked.txt", "one\n"); await commitAll(repoPath, "base"); @@ -331,7 +313,9 @@ describe("Workspace.diffFiles", () => { }); it("supports untracked files outside a sparse-checkout cone", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "inside/base.txt", "base\n"); await commitAll(repoPath, "base"); await runGit(["sparse-checkout", "init", "--cone"], { cwd: repoPath }); @@ -367,7 +351,9 @@ describe("Workspace.diffFiles", () => { }); it("returns placeholders instead of rejecting when an untracked file disappears during indexing", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "base.txt", "base\n"); await commitAll(repoPath, "base"); await write(repoPath, "gone.txt", "gone\n"); @@ -396,7 +382,9 @@ describe("Workspace.diffFiles", () => { }); it("returns an exact truncated untracked slice at the file cap", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await Promise.all([ write(repoPath, "one.txt", "one\n"), write(repoPath, "two.txt", "two\n"), @@ -418,7 +406,9 @@ describe("Workspace.diffFiles", () => { }); it("returns an exact truncated tracked slice after bounded enumeration", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); for (let index = 0; index < 5; index += 1) { await write(repoPath, `tracked-${index}.txt`, `base ${index}\n`); } @@ -443,7 +433,9 @@ describe("Workspace.diffFiles", () => { }); it("keeps rename stats exact in a truncated tracked slice", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "a-before.txt", "one\ntwo\nthree\nfour\nfive\n"); await write(repoPath, "b.txt", "before\n"); await write(repoPath, "c.txt", "before\n"); @@ -472,7 +464,9 @@ describe("Workspace.diffFiles", () => { }); it("keeps full-diff add/delete classification when a truncated slice is below the rename limit", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await runGit(["config", "diff.renameLimit", "15"], { cwd: repoPath }); const originalContents = Array.from({ length: 20 }, (_unused, index) => [`identity ${index}`, ...Array.from({ length: 40 }, () => "shared")] @@ -524,7 +518,9 @@ describe("Workspace.diffFiles", () => { }); it("does not include untracked files for a commit target", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "a.txt", "a\n"); await commitAll(repoPath, "base"); await write(repoPath, "b.txt", "b\n"); @@ -546,7 +542,9 @@ describe("Workspace.diffPatch", () => { const BIG_BUDGET = 10_000_000; it("returns a tracked file patch matching the full-diff slice byte-for-byte", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "alpha.txt", "1\n2\n3\n"); await write(repoPath, "beta.txt", "x\ny\n"); await commitAll(repoPath, "base"); @@ -574,7 +572,9 @@ describe("Workspace.diffPatch", () => { }); it("returns a tracked binary patch byte-equal to git diff --binary in a multi-file page", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await writeBytes( repoPath, "logo.png", @@ -609,7 +609,9 @@ describe("Workspace.diffPatch", () => { }); it("renders untracked files via the alternate-index path", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "tracked.txt", "base\n"); await commitAll(repoPath, "base"); const untrackedPath = "odd [name] file.txt"; @@ -631,7 +633,9 @@ describe("Workspace.diffPatch", () => { }); it("keeps surviving patches when another untracked file disappears during indexing", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "base.txt", "base\n"); await commitAll(repoPath, "base"); await write(repoPath, "gone.txt", "gone\n"); @@ -657,7 +661,9 @@ describe("Workspace.diffPatch", () => { }); it("matches the full-diff slice for an untracked file", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "tracked.txt", "base\n"); await commitAll(repoPath, "base"); await write(repoPath, "untracked.txt", "alpha\nbeta\n"); @@ -678,7 +684,9 @@ describe("Workspace.diffPatch", () => { }); it("returns patches for a mixed tracked + untracked subset", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "tracked.txt", "1\n2\n"); await commitAll(repoPath, "base"); await write(repoPath, "tracked.txt", "1\n2\n3\n"); @@ -700,7 +708,9 @@ describe("Workspace.diffPatch", () => { }); it("bounds a single tracked file whose raw patch exceeds the per-file budget without throwing", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "huge.txt", "seed\n"); await commitAll(repoPath, "base"); @@ -725,7 +735,9 @@ describe("Workspace.diffPatch", () => { }); it("bounds every entry of a page whose raw combined diff exceeds the 16 MB default buffer", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); const fileCount = 6; for (let index = 0; index < fileCount; index += 1) { await write(repoPath, `big-${index}.txt`, "seed\n"); @@ -759,7 +771,9 @@ describe("Workspace.diffPatch", () => { }); it("matches the full-diff slice for a branch_committed rename target", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "mod.txt", "stable\n"); await write(repoPath, "before.txt", "one\ntwo\nthree\nfour\n"); await commitAll(repoPath, "base"); @@ -794,7 +808,9 @@ describe("Workspace.diffPatch", () => { }); it("ignores requested paths that are not in the target's changes", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "real.txt", "a\n"); await commitAll(repoPath, "base"); await write(repoPath, "real.txt", "a\nb\n"); @@ -817,7 +833,9 @@ describe("Workspace.diffPatch", () => { }); it("preserves rename framing when a renamed side's path contains a space", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write( repoPath, "old name.txt", @@ -856,7 +874,9 @@ describe("Workspace.diffPatch", () => { }); it("splits a multi-file page including a space-in-path file from one combined diff", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "my file.txt", "alpha\nbeta\ngamma\n"); await write(repoPath, "normal.txt", "one\ntwo\nthree\n"); await write(repoPath, "untouched.txt", "stable\n"); @@ -904,7 +924,9 @@ describe("Workspace.diffPatch", () => { }); it("splits a multi-file page containing a rename alongside a plain edit", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "before.txt", "alpha\nbeta\ngamma\ndelta\nepsilon\n"); await write(repoPath, "edit.txt", "keep\n"); await commitAll(repoPath, "base"); @@ -953,7 +975,9 @@ describe("Workspace.diffPatch", () => { }); it("returns a non-empty patch for a path with non-ASCII characters", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "café.txt", "un\ndeux\ntrois\n"); await write(repoPath, "plain.txt", "stable\n"); await commitAll(repoPath, "base"); @@ -976,7 +1000,9 @@ describe("Workspace.diffPatch", () => { }); it("truncates on a UTF-8 boundary without emitting U+FFFD or overshooting", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-diff-repo-"), { + commit: false, + }); await write(repoPath, "seed.txt", "seed\n"); await commitAll(repoPath, "base"); diff --git a/packages/host-workspace/test/workspace.test.ts b/packages/host-workspace/test/workspace.test.ts index cd258cde77d..d0e37af922c 100644 --- a/packages/host-workspace/test/workspace.test.ts +++ b/packages/host-workspace/test/workspace.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { WorkspaceChangeStats } from "@bb/domain"; -import { createDeferredPromise } from "@bb/test-helpers"; +import { createDeferredPromise, initRepo, makeTempDir } from "@bb/test-helpers"; import { ProcessLocalQueuedLockTimeoutError, withProcessLocalQueuedLocks, @@ -13,8 +13,6 @@ import { WorkspaceError } from "../src/git.js"; import { runGit } from "../src/git.js"; import { withCheckoutMutationLock } from "../src/checkout-mutation-lock.js"; -const tempDirs: string[] = []; - type DiffStats = { filesCount: number; insertions: number; @@ -25,23 +23,6 @@ function waitForLockContention(): Promise { return new Promise((resolve) => setTimeout(resolve, 100)); } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function initRepo(): Promise { - const repoPath = await makeTempDir("bb-workspace-repo-"); - await runGit(["init", "-b", "main"], { cwd: repoPath }); - await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); - await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); - await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); - await runGit(["add", "README.md"], { cwd: repoPath }); - await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath }); - return repoPath; -} - function parseFirstIntegerMatch(text: string, pattern: RegExp): number { const value = Number.parseInt(text.match(pattern)?.[1] ?? "0", 10); return Number.isFinite(value) ? value : 0; @@ -69,7 +50,7 @@ type PrimaryAndFeatureWorktree = { }; async function createPrimaryAndFeatureWorktree(): Promise { - const primaryRepo = await initRepo(); + const primaryRepo = await initRepo(await makeTempDir("bb-workspace-repo-")); const worktreeParent = await makeTempDir( "bb-workspace-squash-worktree-parent-", ); @@ -93,22 +74,9 @@ async function mergeFeatureIntoMainWithSquash( }); } -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => - fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }), - ), - ); -}); - describe("Workspace", () => { it("reports clean, dirty, untracked-only, and mixed workspace states", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); expect((await workspace.getStatus()).workingTree.state).toBe("clean"); @@ -146,7 +114,7 @@ describe("Workspace", () => { }); it("reports deleted tracked files as dirty file changes", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await fs.rm(path.join(repoPath, "README.md")); @@ -164,7 +132,7 @@ describe("Workspace", () => { }); it("joins per-file numstat to a rename+modify under the new path", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await runGit(["mv", "README.md", "NOTES.md"], { cwd: repoPath }); @@ -188,7 +156,7 @@ describe("Workspace", () => { }); it("reports null per-file stats for binary changes and excludes them from totals", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const binaryPath = path.join(repoPath, "data.bin"); await fs.writeFile(binaryPath, Buffer.from([0, 1, 2, 3, 0xff, 0xfe])); await runGit(["add", "data.bin"], { cwd: repoPath }); @@ -223,7 +191,7 @@ describe("Workspace", () => { }); it("changes the local state fingerprint when local checkout state changes", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); const initialFingerprint = await workspace.getLocalStateFingerprint(); @@ -238,7 +206,7 @@ describe("Workspace", () => { }); it("fingerprints an untracked path without reading its contents", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); const initialFingerprint = await workspace.getLocalStateFingerprint(); @@ -253,7 +221,7 @@ describe("Workspace", () => { }); it("changes the shared git refs fingerprint only when refs change", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); const initialFingerprint = await workspace.getSharedGitRefsFingerprint(); @@ -268,7 +236,7 @@ describe("Workspace", () => { }); it("returns grouped status details only when merge-base data is requested", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile(path.join(repoPath, "README.md"), "feature\n", "utf8"); await runGit(["add", "README.md"], { cwd: repoPath }); @@ -364,7 +332,7 @@ describe("Workspace", () => { }); it("reports untracked files plus committed unmerged changes as dirty_and_committed_unmerged", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile(path.join(repoPath, "README.md"), "feature\n", "utf8"); await runGit(["add", "README.md"], { cwd: repoPath }); @@ -399,7 +367,7 @@ describe("Workspace", () => { }); it("reports branches that are only behind their merge base as clean", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await runGit(["checkout", "main"], { cwd: repoPath }); await fs.writeFile( @@ -614,7 +582,7 @@ describe("Workspace", () => { }); it("returns diff content for each supported target", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile(path.join(repoPath, "README.md"), "feature\n", "utf8"); await runGit(["add", "README.md"], { cwd: repoPath }); @@ -665,7 +633,7 @@ describe("Workspace", () => { }); it("aligns committed-only status stats with all and committed diffs", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile(path.join(repoPath, "README.md"), "feature\n", "utf8"); await runGit(["add", "README.md"], { cwd: repoPath }); @@ -693,7 +661,7 @@ describe("Workspace", () => { }); it("marks status line stats incomplete when untracked content is omitted", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile( path.join(repoPath, "README.md"), "hello\npending\n", @@ -730,7 +698,7 @@ describe("Workspace", () => { }); it("enriches small untracked status snapshots within file and byte budgets", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile( path.join(repoPath, "README.md"), "hello\npending\n", @@ -772,7 +740,7 @@ describe("Workspace", () => { }); it("keeps tracked deletion stats separate from an untracked replacement at the same path", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile( path.join(repoPath, "replacement.txt"), "one\ntwo\nthree\n", @@ -811,7 +779,7 @@ describe("Workspace", () => { }); it("enriches eligible untracked files when another entry is a nested repository", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile(path.join(repoPath, "notes.txt"), "one\ntwo\n", "utf8"); const nestedRepoPath = path.join(repoPath, "vendor"); await fs.mkdir(nestedRepoPath); @@ -845,7 +813,7 @@ describe("Workspace", () => { }); it("leaves untracked status stats unknown when either enrichment budget is exceeded", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile(path.join(repoPath, "one.txt"), "one\n", "utf8"); await fs.writeFile(path.join(repoPath, "two.txt"), "two\n", "utf8"); const workspace = new Workspace(repoPath); @@ -870,7 +838,7 @@ describe("Workspace", () => { }); it("keeps tracked status totals explicitly incomplete in a mixed workspace", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile(path.join(repoPath, "README.md"), "feature\n", "utf8"); await runGit(["add", "README.md"], { cwd: repoPath }); @@ -902,7 +870,7 @@ describe("Workspace", () => { }); it("truncates large git diff output before the process buffer fails", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await runGit(["checkout", "-b", "feature"], { cwd: repoPath }); await fs.writeFile( path.join(repoPath, "README.md"), @@ -926,7 +894,7 @@ describe("Workspace", () => { }); it("includes untracked files in one combined diff", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await Promise.all( @@ -949,7 +917,7 @@ describe("Workspace", () => { }); it("bounds full-diff untracked content and reports truncation", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); await fs.writeFile(path.join(repoPath, "a.txt"), "first\n", "utf8"); await fs.writeFile(path.join(repoPath, "b.txt"), "second\n", "utf8"); @@ -966,7 +934,7 @@ describe("Workspace", () => { }); it("commits staged work", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await fs.writeFile(path.join(repoPath, "README.md"), "commit me\n", "utf8"); @@ -981,7 +949,7 @@ describe("Workspace", () => { }); it("throws a typed no_changes error when there is nothing to commit", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await expect( @@ -1000,7 +968,7 @@ describe("Workspace", () => { }); it("serializes same-checkout mutations", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const workspace = new Workspace(repoPath); await fs.writeFile(path.join(repoPath, "README.md"), "pending\n", "utf8"); @@ -1030,7 +998,7 @@ describe("Workspace", () => { }); it("does not serialize different linked worktree checkout mutations", async () => { - const repoPath = await initRepo(); + const repoPath = await initRepo(await makeTempDir("bb-workspace-repo-")); const worktreeParent = await makeTempDir("bb-workspace-lock-worktrees-"); const worktreePath = path.join(worktreeParent, "feature"); await runGit(["worktree", "add", "-b", "feature", worktreePath, "main"], { diff --git a/packages/provider-bridge-acp/package.json b/packages/provider-bridge-acp/package.json index 406a3cfd9b9..a99980dfdf2 100644 --- a/packages/provider-bridge-acp/package.json +++ b/packages/provider-bridge-acp/package.json @@ -28,6 +28,7 @@ "@bb/process-utils": "workspace:*" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "^22.0.0", diff --git a/packages/provider-bridge-acp/src/bridge/agent-connection.test.ts b/packages/provider-bridge-acp/src/bridge/agent-connection.test.ts index 1b835eb74dd..b4a92834fa8 100644 --- a/packages/provider-bridge-acp/src/bridge/agent-connection.test.ts +++ b/packages/provider-bridge-acp/src/bridge/agent-connection.test.ts @@ -12,6 +12,7 @@ import { type AcpAgentConnection, type AcpAgentExitInfo, } from "./agent-connection.js"; +import { createDeferredPromise } from "@bb/test-helpers"; const EPIPE_PAYLOAD_SIZE = 1024 * 1024; @@ -31,22 +32,6 @@ function closedStdinAgent(): { command: string; args: string[] } { }; } -function deferred(): { - promise: Promise; - resolve(value: T): void; -} { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { - promise, - resolve(value) { - resolvePromise?.(value); - }, - }; -} - async function stopConnection( connection: AcpAgentConnection, exit: Promise, @@ -89,8 +74,8 @@ describe("formatAgentError", () => { describe("ACP agent stdio lifecycle", () => { it("does not surface a closed agent stdin as an unhandled EPIPE", async () => { - const ready = deferred(); - const exited = deferred(); + const ready = createDeferredPromise(); + const exited = createDeferredPromise(); const connection = createAcpAgentConnection({ recordThreadId: null, ...closedStdinAgent(), @@ -115,8 +100,8 @@ describe("ACP agent stdio lifecycle", () => { }); it("rejects requests and stops an agent that closes stdin but stays alive", async () => { - const ready = deferred(); - const exited = deferred(); + const ready = createDeferredPromise(); + const exited = createDeferredPromise(); const connection = createAcpAgentConnection({ recordThreadId: null, ...closedStdinAgent(), @@ -164,8 +149,8 @@ describe("ACP agent stdio lifecycle", () => { }); it("makes an intentionally stopped connection unavailable before stdin teardown", async () => { - const ready = deferred(); - const exited = deferred(); + const ready = createDeferredPromise(); + const exited = createDeferredPromise(); const connection = createAcpAgentConnection({ recordThreadId: null, command: process.execPath, @@ -236,8 +221,8 @@ describe("ACP agent stdio lifecycle", () => { it("ignores an ACP request emitted during SIGTERM", async () => { const workspace = mkdtempSync(join(tmpdir(), "bb-acp-stop-")); const lateWrite = join(workspace, "late-write.txt"); - const ready = deferred(); - const exited = deferred(); + const ready = createDeferredPromise(); + const exited = createDeferredPromise(); let requestCount = 0; const connection = createAcpAgentConnection({ recordThreadId: null, @@ -276,7 +261,7 @@ describe("ACP agent stdio lifecycle", () => { }); it("rejects pending requests when the agent exits", async () => { - const exited = deferred(); + const exited = createDeferredPromise(); const connection = createAcpAgentConnection({ recordThreadId: null, command: process.execPath, diff --git a/packages/provider-bridge-acp/src/bridge/cursor-mcp-approval.test.ts b/packages/provider-bridge-acp/src/bridge/cursor-mcp-approval.test.ts index fee8ad3c818..e6a3edfa37a 100644 --- a/packages/provider-bridge-acp/src/bridge/cursor-mcp-approval.test.ts +++ b/packages/provider-bridge-acp/src/bridge/cursor-mcp-approval.test.ts @@ -1,7 +1,7 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDirSync } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { approveCursorSessionMcpServer, buildCursorMcpApprovalIdentifier, @@ -9,14 +9,6 @@ import { } from "./cursor-mcp-approval.js"; import type { AcpMcpServerConfig } from "./tool-proxy-mcp.js"; -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const path = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(path); - return path; -} - function mcpConfig(threadId = "thread-1"): AcpMcpServerConfig { return { name: "bb-bridge", @@ -29,12 +21,6 @@ function mcpConfig(threadId = "thread-1"): AcpMcpServerConfig { }; } -afterEach(() => { - for (const path of tempDirs.splice(0)) { - rmSync(path, { recursive: true, force: true }); - } -}); - describe("Cursor ACP session MCP approvals", () => { it("matches Cursor's approval fingerprint for a normalized stdio server", () => { expect( @@ -46,8 +32,8 @@ describe("Cursor ACP session MCP approvals", () => { }); it("does not touch Cursor data for other ACP agents", async () => { - const cursorDataDir = makeTempDir("bb-cursor-data-"); - const workspace = makeTempDir("bb-cursor-workspace-"); + const cursorDataDir = makeTempDirSync("bb-cursor-data-"); + const workspace = makeTempDirSync("bb-cursor-workspace-"); await expect( approveCursorSessionMcpServer({ @@ -61,8 +47,8 @@ describe("Cursor ACP session MCP approvals", () => { }); it("preserves unrelated approvals and removes its session approval on release", async () => { - const cursorDataDir = makeTempDir("bb-cursor-data-"); - const workspace = makeTempDir("bb-cursor-workspace-"); + const cursorDataDir = makeTempDirSync("bb-cursor-data-"); + const workspace = makeTempDirSync("bb-cursor-workspace-"); const env = { CURSOR_DATA_DIR: cursorDataDir }; const first = await approveCursorSessionMcpServer({ agentCommand: "/opt/cursor/cursor-agent", @@ -108,8 +94,8 @@ describe("Cursor ACP session MCP approvals", () => { }); it("does not remove an approval Cursor already had", async () => { - const cursorDataDir = makeTempDir("bb-cursor-data-"); - const workspace = makeTempDir("bb-cursor-workspace-"); + const cursorDataDir = makeTempDirSync("bb-cursor-data-"); + const workspace = makeTempDirSync("bb-cursor-workspace-"); const env = { CURSOR_DATA_DIR: cursorDataDir }; const installed = await approveCursorSessionMcpServer({ agentCommand: "cursor-agent", diff --git a/packages/provider-bridge-acp/src/bridge/mcp-server-entry.test.ts b/packages/provider-bridge-acp/src/bridge/mcp-server-entry.test.ts index 1ffd4573649..bdcb8aa087a 100644 --- a/packages/provider-bridge-acp/src/bridge/mcp-server-entry.test.ts +++ b/packages/provider-bridge-acp/src/bridge/mcp-server-entry.test.ts @@ -1,8 +1,7 @@ import { stopProcessGroupLeaderFirst } from "@bb/process-utils"; +import { makeTempDirSync } from "@bb/test-helpers"; import { spawn, type ChildProcess } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; @@ -34,7 +33,6 @@ interface AdvertisedMcpServer { } const children: { child: ChildProcess; closed: Promise }[] = []; -const tempDirs: string[] = []; const bridgeLines: BridgeLine[] = []; let bridgeStderr = ""; let nextRequestId = 1; @@ -46,12 +44,6 @@ function trackChild(child: ChildProcess): void { children.push({ child, closed }); } -function makeTempDir(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function waitFor( pick: () => T | undefined, what: string, @@ -260,17 +252,14 @@ afterEach(async () => { await closed; }), ); - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } bridgeLines.length = 0; bridgeStderr = ""; }); describe("bb-bridge MCP server entry point (#1918)", () => { it("advertises an MCP server command that answers MCP initialize when the bridge runs under the bootstrap", async () => { - const dataDir = makeTempDir("bb-acp-mcp-entry-data-"); - const workspaceDir = makeTempDir("bb-acp-mcp-entry-ws-"); + const dataDir = makeTempDirSync("bb-acp-mcp-entry-data-"); + const workspaceDir = makeTempDirSync("bb-acp-mcp-entry-ws-"); const bridge = spawnBridgeLikeTheAgentRuntime(dataDir); const config = await readAdvertisedMcpServer(bridge, workspaceDir); diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 449539f66f6..3882a9337da 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -33,6 +33,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@types/node": "^22.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/packages/scripts/test/pid-file.test.ts b/packages/scripts/test/pid-file.test.ts index c4413a89fb1..c5192efc41b 100644 --- a/packages/scripts/test/pid-file.test.ts +++ b/packages/scripts/test/pid-file.test.ts @@ -1,24 +1,11 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { makeTempDir } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { readRunningPid, writePidFile } from "../src/lib/pid-file.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); describe("pid-file helpers", () => { diff --git a/packages/scripts/test/request-dev-restart.test.ts b/packages/scripts/test/request-dev-restart.test.ts index 8ab6418b97d..3622f318fbb 100644 --- a/packages/scripts/test/request-dev-restart.test.ts +++ b/packages/scripts/test/request-dev-restart.test.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; +import { makeTempDir } from "@bb/test-helpers"; import { parseTarget, readRunningSupervisorPid, @@ -19,24 +20,12 @@ import { expectedDevPorts, } from "./dev-instance-expectations.js"; -const tempDirs: string[] = []; const testDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(testDir, "..", "..", ".."); -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); describe("request-dev-restart", () => { diff --git a/packages/scripts/test/run-dev-supervisor.test.ts b/packages/scripts/test/run-dev-supervisor.test.ts index 24b13f44152..8d7284fc525 100644 --- a/packages/scripts/test/run-dev-supervisor.test.ts +++ b/packages/scripts/test/run-dev-supervisor.test.ts @@ -17,11 +17,7 @@ import type { ProcessExitResult, } from "../src/lib/process-helpers.js"; import type { WritePidFileRequest } from "../src/lib/pid-file.js"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; -} +import { createDeferredPromise } from "@bb/test-helpers"; interface FakeRuntimeWaiter { predicate(): boolean; @@ -55,23 +51,6 @@ interface CreateSupervisorOptionsArgs { const SERVICE_NAME = "scripts-test-dev-supervisor"; -function createDeferred(): Deferred { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - return { - promise, - resolve(value: T): void { - if (!resolvePromise) { - throw new Error("Deferred promise was not initialized"); - } - resolvePromise(value); - }, - }; -} - function createUnexpectedExitPlan(runtimeMs: number): ExitingFakeChildPlan { return { mode: "exit", @@ -132,7 +111,7 @@ class FakeTimer implements DevSupervisorTimer { class FakeChildProcess implements DevSupervisorChildProcess { exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; - private readonly exitDeferred = createDeferred(); + private readonly exitDeferred = createDeferredPromise(); constructor( private readonly runtime: FakeDevSupervisorRuntime, @@ -308,7 +287,7 @@ class FakeDevSupervisorRuntime implements DevSupervisorRuntime { return Promise.resolve(); } - const deferred = createDeferred(); + const deferred = createDeferredPromise(); this.waiters.push({ predicate, resolve: () => { diff --git a/packages/scripts/test/run-dev.test.ts b/packages/scripts/test/run-dev.test.ts index 2290feb2277..3edf34e0fa2 100644 --- a/packages/scripts/test/run-dev.test.ts +++ b/packages/scripts/test/run-dev.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -7,6 +6,7 @@ import { resolveInheritedDevSkillsRootPaths, toDevProcessEnv, } from "@bb/config/runtime"; +import { makeTempDir } from "@bb/test-helpers"; import { createStartWorktreeCommand, resolveDevCloud, @@ -22,14 +22,6 @@ import { expectedDevServerUrl, } from "./dev-instance-expectations.js"; -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - async function pathExists(pathToCheck: string): Promise { try { await fs.access(pathToCheck); @@ -42,13 +34,8 @@ async function pathExists(pathToCheck: string): Promise { } } -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); describe("run-dev", () => { diff --git a/packages/scripts/test/run-host-daemon.test.ts b/packages/scripts/test/run-host-daemon.test.ts index f1050ec0ca7..02ba6c3f906 100644 --- a/packages/scripts/test/run-host-daemon.test.ts +++ b/packages/scripts/test/run-host-daemon.test.ts @@ -7,6 +7,7 @@ import { HOST_AUTH_FILE_NAME, HOST_ID_FILE_NAME, } from "@bb/host-daemon-contract"; +import { makeTempDir } from "@bb/test-helpers"; import { expectedDevDataDir, expectedDevServerUrl, @@ -14,7 +15,6 @@ import { import type * as RunHostDaemonModule from "../src/commands/run-host-daemon.js"; import type { HostDaemonRuntimeEnvironment } from "../src/lib/host-daemon-runtime.js"; -const tempDirs: string[] = []; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, "..", "..", ".."); let runHostDaemon: typeof RunHostDaemonModule; @@ -47,12 +47,6 @@ function createTestRuntimeEnv({ }; } -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - beforeEach(async () => { vi.resetModules(); vi.stubEnv("BB_DATA_DIR", "/tmp/bb-run-host-daemon-test"); @@ -61,14 +55,9 @@ beforeEach(async () => { runHostDaemon = await import("../src/commands/run-host-daemon.js"); }); -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); }); describe("run-host-daemon auto join", () => { diff --git a/packages/secret-storage/package.json b/packages/secret-storage/package.json index 9b5aaa46abf..901882b2d74 100644 --- a/packages/secret-storage/package.json +++ b/packages/secret-storage/package.json @@ -16,6 +16,7 @@ "test": "vitest run --config vitest.config.ts" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@types/node": "^22.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/packages/secret-storage/test/secret-file.test.ts b/packages/secret-storage/test/secret-file.test.ts index 09cf5445081..f97b2aacc42 100644 --- a/packages/secret-storage/test/secret-file.test.ts +++ b/packages/secret-storage/test/secret-file.test.ts @@ -1,25 +1,9 @@ -import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; -import os from "node:os"; +import { stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { readOrCreateSecretFile } from "../src/index.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-secret-file-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - describe("secret file", () => { it("reuses the same secret across repeated reads", async () => { const dataDir = await makeTempDir(); diff --git a/packages/secret-storage/test/write-secret-file.test.ts b/packages/secret-storage/test/write-secret-file.test.ts index a86062f3aad..89878c163ad 100644 --- a/packages/secret-storage/test/write-secret-file.test.ts +++ b/packages/secret-storage/test/write-secret-file.test.ts @@ -1,25 +1,9 @@ -import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; -import os from "node:os"; +import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { deleteSecretFile, writeSecretFile } from "../src/index.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-write-secret-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - describe("writeSecretFile", () => { it("writes the value with 0600 mode, creating parent directories", async () => { const dir = await makeTempDir(); diff --git a/packages/server-archive/package.json b/packages/server-archive/package.json index 6539d04e2e6..45e44430d3c 100644 --- a/packages/server-archive/package.json +++ b/packages/server-archive/package.json @@ -22,6 +22,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/test-helpers": "workspace:*", "@bb/tsconfig": "workspace:*", "@types/node": "^22.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/packages/server-archive/test/archive.test.ts b/packages/server-archive/test/archive.test.ts index 7206b86cc7e..fa5c0dc2bc3 100644 --- a/packages/server-archive/test/archive.test.ts +++ b/packages/server-archive/test/archive.test.ts @@ -2,20 +2,18 @@ import { createHash, randomBytes } from "node:crypto"; import { chmod, mkdir, - mkdtemp, readdir, readFile, - rm, stat, symlink, writeFile, } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { Header } from "tar/header"; import type { EntryTypeName } from "tar/types"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { extractServerArchive, SERVER_ARCHIVE_VERSION, @@ -26,22 +24,6 @@ import { writeServerArchive, } from "../src/index.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-archive-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - const MANIFEST_INPUT: ServerArchiveManifestInput = { createdAt: Date.UTC(2026, 8, 15, 12, 0, 0), bbVersion: "0.43.1", diff --git a/packages/server-archive/test/import-journal.test.ts b/packages/server-archive/test/import-journal.test.ts index 01b1c0abc0b..3fca668ab80 100644 --- a/packages/server-archive/test/import-journal.test.ts +++ b/packages/server-archive/test/import-journal.test.ts @@ -1,15 +1,7 @@ -import { - mkdir, - mkdtemp, - readdir, - readFile, - rename, - rm, - writeFile, -} from "node:fs/promises"; -import os from "node:os"; +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { extractServerArchive, installImportedServerFiles, @@ -26,22 +18,6 @@ import { writeServerImportFile, } from "../src/index.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-journal-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - async function writeDataFile( dataDir: string, relativePath: string, diff --git a/packages/server-archive/test/import-lock.test.ts b/packages/server-archive/test/import-lock.test.ts index 401972a3869..c2f33c4ba57 100644 --- a/packages/server-archive/test/import-lock.test.ts +++ b/packages/server-archive/test/import-lock.test.ts @@ -1,15 +1,8 @@ -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import os from "node:os"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { withFileLock } from "@bb/config/file-lock"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it, vi } from "vitest"; import { extractServerArchive, installImportedServerFiles, @@ -47,22 +40,6 @@ vi.mock("@bb/config/file-lock", async (importOriginal) => { }; }); -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-lock-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - async function writeDataFile( dataDir: string, relativePath: string, diff --git a/packages/server-archive/test/import.test.ts b/packages/server-archive/test/import.test.ts index 5fd5bbb3815..3683cb7404d 100644 --- a/packages/server-archive/test/import.test.ts +++ b/packages/server-archive/test/import.test.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { mkdir, - mkdtemp, readdir, readFile, rm, @@ -9,9 +8,9 @@ import { symlink, writeFile, } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { archiveExistingServerData, discardImportBackups, @@ -35,22 +34,6 @@ import { writeFixtureFile, } from "./data-dir-fixtures.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-import-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - async function writeDataFile( dataDir: string, relativePath: string, diff --git a/packages/server-archive/test/inventory.test.ts b/packages/server-archive/test/inventory.test.ts index 6f46b47c5ea..d3c66a7ffc8 100644 --- a/packages/server-archive/test/inventory.test.ts +++ b/packages/server-archive/test/inventory.test.ts @@ -1,7 +1,7 @@ -import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; -import os from "node:os"; +import { mkdir, symlink } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { listServerOwnedEntries } from "../src/index.js"; import { fixtureBody, @@ -10,22 +10,6 @@ import { writeFixtureFile, } from "./data-dir-fixtures.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-inventory-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - describe("listServerOwnedEntries", () => { it("includes server-owned files and excludes host-owned, cache, and unknown entries", async () => { const dataDir = await makeTempDir(); diff --git a/packages/server-archive/test/markers.test.ts b/packages/server-archive/test/markers.test.ts index dee8edebdda..f9ed0115ef1 100644 --- a/packages/server-archive/test/markers.test.ts +++ b/packages/server-archive/test/markers.test.ts @@ -1,7 +1,7 @@ -import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; -import os from "node:os"; +import { readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "@bb/test-helpers"; +import { describe, expect, it } from "vitest"; import { LAST_SERVER_MOVE_FILE_NAME, readLastServerMoveFile, @@ -21,22 +21,6 @@ import { writeServerMovedFile, } from "../src/index.js"; -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "bb-server-markers-")); - tempDirs.push(tempDir); - return tempDir; -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((tempDir) => rm(tempDir, { force: true, recursive: true })), - ); -}); - const MOVED_FILE: ServerMovedFile = { version: 1, moveId: "move-1", diff --git a/packages/test-helpers/package.json b/packages/test-helpers/package.json index 3c72b67791c..10fa936c48c 100644 --- a/packages/test-helpers/package.json +++ b/packages/test-helpers/package.json @@ -28,6 +28,10 @@ "@bb/tsconfig": "workspace:*", "@types/node": "^22.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2", - "typescript-7": "npm:typescript@^7.0.2" + "typescript-7": "npm:typescript@^7.0.2", + "vitest": "^4.1.1" + }, + "peerDependencies": { + "vitest": "^4.1.1" } } diff --git a/packages/test-helpers/src/git-repo.ts b/packages/test-helpers/src/git-repo.ts new file mode 100644 index 00000000000..489ff92ac09 --- /dev/null +++ b/packages/test-helpers/src/git-repo.ts @@ -0,0 +1,26 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} + +export async function initRepo( + directory: string, + { commit = true }: { commit?: boolean } = {}, +): Promise { + await git(directory, ["init", "-q", "-b", "main"]); + await git(directory, ["config", "user.name", "BB Tests"]); + await git(directory, ["config", "user.email", "bb@example.com"]); + await git(directory, ["config", "core.autocrlf", "false"]); + if (commit) { + await writeFile(join(directory, "README.md"), "hello\n", "utf8"); + await git(directory, ["add", "README.md"]); + await git(directory, ["commit", "-q", "-m", "Initial commit"]); + } + return directory; +} diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts index e0efc28fd5c..86f13c80f61 100644 --- a/packages/test-helpers/src/index.ts +++ b/packages/test-helpers/src/index.ts @@ -1,6 +1,7 @@ export { collectOptionalFieldPaths } from "./collect-optional-field-paths.js"; export { createDeferredPromise } from "./deferred-promise.js"; export type { DeferredPromise } from "./deferred-promise.js"; +export { initRepo } from "./git-repo.js"; export { listPreferredTestModels, resolvePreferredTestModel, @@ -23,3 +24,4 @@ export { resolveProviderCorpusDir, } from "./provider-corpus.js"; export type { CorpusThread } from "./provider-corpus.js"; +export { makeTempDir, makeTempDirSync } from "./temp-dir.js"; diff --git a/packages/test-helpers/src/temp-dir.ts b/packages/test-helpers/src/temp-dir.ts new file mode 100644 index 00000000000..3ddfe9ef457 --- /dev/null +++ b/packages/test-helpers/src/temp-dir.ts @@ -0,0 +1,25 @@ +import { mkdtempSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { onTestFinished } from "vitest"; + +function removeAfterTest(directory: string): string { + onTestFinished(() => + rm(directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }), + ); + return directory; +} + +export async function makeTempDir(prefix = "bb-test-"): Promise { + return removeAfterTest(await mkdtemp(join(tmpdir(), prefix))); +} + +export function makeTempDirSync(prefix = "bb-test-"): string { + return removeAfterTest(mkdtempSync(join(tmpdir(), prefix))); +} diff --git a/plugins/theme-preview/server.test.ts b/plugins/theme-preview/server.test.ts index e5e7d5b6c31..5bdecd41023 100644 --- a/plugins/theme-preview/server.test.ts +++ b/plugins/theme-preview/server.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { + createFakePluginHost, + type FakeSdkOverrides, +} from "@get-bb/plugin-sdk/testing"; import plugin, { buildCatalog, classifySelector, @@ -79,6 +82,19 @@ describe("parseThemeSwatches", () => { }); }); +function themeHost(sdk: FakeSdkOverrides) { + return createFakePluginHost({ + pluginId: "theme-preview", + sdk: { plugins: { list: async () => ({ plugins: [] }) }, ...sdk }, + }); +} + +function warnings(harness: ReturnType["harness"]): string[] { + return harness.logEntries + .filter((entry) => entry.level === "warn") + .map((entry) => entry.message); +} + describe("createCatalogLoader", () => { it("shares one catalog refresh across overlapping callers", async () => { let catalogCalls = 0; @@ -86,19 +102,16 @@ describe("createCatalogLoader", () => { const catalogBlocked = new Promise((resolve) => { releaseCatalog = resolve; }); - const bb = { - sdk: { - theme: { - catalog: async () => { - catalogCalls += 1; - await catalogBlocked; - return { active: { themeId: "default" }, custom: [], dir: null }; - }, + const { bb } = themeHost({ + theme: { + catalog: async () => { + catalogCalls += 1; + await catalogBlocked; + return { active: { themeId: "default" }, custom: [], dir: null }; }, - plugins: { list: async () => ({ plugins: [] }) }, }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + plugins: { list: async () => ({ plugins: [] }) }, + }); const catalogLoader = createCatalogLoader(bb); const first = catalogLoader.catalog(); @@ -114,33 +127,29 @@ describe("createCatalogLoader", () => { try { let catalogCalls = 0; let firstSignal: AbortSignal | undefined; - const warn = vi.fn(); - const bb = { - sdk: { - theme: { - catalog: ({ signal }: { signal?: AbortSignal } = {}) => { - catalogCalls += 1; - if (catalogCalls === 1) { - firstSignal = signal; - return new Promise(() => undefined); - } - return Promise.resolve({ - active: { themeId: "default" }, - custom: [], - dir: null, - }); - }, + const { bb, harness } = themeHost({ + theme: { + catalog: ({ signal }: { signal?: AbortSignal } = {}) => { + catalogCalls += 1; + if (catalogCalls === 1) { + firstSignal = signal; + return new Promise(() => undefined); + } + return Promise.resolve({ + active: { themeId: "default" }, + custom: [], + dir: null, + }); }, - plugins: { list: async () => ({ plugins: [] }) }, }, - log: { info() {}, warn }, - } as unknown as BbPluginApi; + plugins: { list: async () => ({ plugins: [] }) }, + }); const catalogLoader = createCatalogLoader(bb); const failed = catalogLoader.catalog().catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(5_000); - expect(warn).toHaveBeenCalledWith( + expect(warnings(harness)).toContain( "theme-preview: theme catalog still pending after 5000ms", ); await vi.advanceTimersByTimeAsync(10_000); @@ -165,34 +174,30 @@ describe("createCatalogLoader", () => { try { let pluginListCalls = 0; let firstSignal: AbortSignal | undefined; - const warn = vi.fn(); - const bb = { - sdk: { - theme: { - catalog: async () => ({ - active: { themeId: "default" }, - custom: [], - dir: null, - }), - }, - plugins: { - list: ({ signal }: { signal?: AbortSignal } = {}) => { - pluginListCalls += 1; - if (pluginListCalls === 1) { - firstSignal = signal; - return new Promise(() => undefined); - } - return Promise.resolve({ plugins: [] }); - }, + const { bb, harness } = themeHost({ + theme: { + catalog: async () => ({ + active: { themeId: "default" }, + custom: [], + dir: null, + }), + }, + plugins: { + list: ({ signal }: { signal?: AbortSignal } = {}) => { + pluginListCalls += 1; + if (pluginListCalls === 1) { + firstSignal = signal; + return new Promise(() => undefined); + } + return Promise.resolve({ plugins: [] }); }, }, - log: { info() {}, warn }, - } as unknown as BbPluginApi; + }); const catalogLoader = createCatalogLoader(bb); const degraded = catalogLoader.catalog(); await vi.advanceTimersByTimeAsync(5_000); - expect(warn).toHaveBeenCalledWith( + expect(warnings(harness)).toContain( "theme-preview: plugin list still pending after 5000ms", ); await vi.advanceTimersByTimeAsync(10_000); @@ -201,7 +206,7 @@ describe("createCatalogLoader", () => { expect.objectContaining({ activeThemeId: "default" }), ); expect(firstSignal?.aborted).toBe(true); - expect(warn).toHaveBeenCalledWith( + expect(warnings(harness)).toContain( "theme-preview: plugin list unavailable: Error: theme-preview: plugin list timed out after 15000ms", ); await expect(catalogLoader.catalog()).resolves.toEqual( @@ -220,27 +225,24 @@ describe("createCatalogLoader", () => { const pluginListBlocked = new Promise((resolve) => { releasePluginList = resolve; }); - const bb = { - sdk: { - theme: { - catalog: async () => ({ - active: { themeId: activeThemeId }, - custom: ["theme-a", "theme-b"], - dir: null, - }), - set: async (themeId: string) => { - activeThemeId = themeId; - }, + const { bb } = themeHost({ + theme: { + catalog: async () => ({ + active: { themeId: activeThemeId }, + custom: ["theme-a", "theme-b"], + dir: null, + }), + set: async (themeId: string) => { + activeThemeId = themeId; }, - plugins: { - list: async () => { - if (blockPluginList) await pluginListBlocked; - return { plugins: [] }; - }, + }, + plugins: { + list: async () => { + if (blockPluginList) await pluginListBlocked; + return { plugins: [] }; }, }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + }); const catalogLoader = createCatalogLoader(bb); await catalogLoader.catalog(); @@ -272,27 +274,24 @@ describe("createCatalogLoader", () => { const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; }); - const bb = { - sdk: { - theme: { - catalog: async () => ({ - active: { themeId: activeThemeId }, - custom: [], - dir: null, - }), - set: async (themeId: string) => { - setCalls.push(themeId); - if (themeId === "theme-a") { - markFirstStarted(); - await firstBlocked; - } - activeThemeId = themeId; - }, + const { bb } = themeHost({ + theme: { + catalog: async () => ({ + active: { themeId: activeThemeId }, + custom: [], + dir: null, + }), + set: async (themeId: string) => { + setCalls.push(themeId); + if (themeId === "theme-a") { + markFirstStarted(); + await firstBlocked; + } + activeThemeId = themeId; }, - plugins: { list: async () => ({ plugins: [] }) }, }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + plugins: { list: async () => ({ plugins: [] }) }, + }); const catalogLoader = createCatalogLoader(bb); const first = catalogLoader.setTheme("theme-a"); @@ -324,29 +323,26 @@ describe("createCatalogLoader", () => { resumeFirstPluginList = resolve; }); const setCalls: string[] = []; - const bb = { - sdk: { - theme: { - catalog: async () => ({ - active: { themeId: activeThemeId }, - custom: ["theme-a", "theme-b"], - dir: directory, - }), - set: async (themeId: string) => { - setCalls.push(themeId); - activeThemeId = themeId; - }, + const { bb } = themeHost({ + theme: { + catalog: async () => ({ + active: { themeId: activeThemeId }, + custom: ["theme-a", "theme-b"], + dir: directory, + }), + set: async (themeId: string) => { + setCalls.push(themeId); + activeThemeId = themeId; }, - plugins: { - list: async () => { - pluginListCalls += 1; - if (pluginListCalls === 1) await firstPluginList; - return { plugins: [] }; - }, + }, + plugins: { + list: async () => { + pluginListCalls += 1; + if (pluginListCalls === 1) await firstPluginList; + return { plugins: [] }; }, }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + }); const catalogLoader = createCatalogLoader(bb); const stale = catalogLoader.catalog(); @@ -368,22 +364,19 @@ describe("createCatalogLoader", () => { await mkdir(themeDirectory); await writeFile(filePath, ":root { --canvas: #ffffff; }"); const setCalls: string[] = []; - const bb = { - sdk: { - theme: { - catalog: async () => ({ - active: { themeId: "theme-a" }, - custom: ["theme-a"], - dir: directory, - }), - set: async (themeId: string) => { - setCalls.push(themeId); - }, + const { bb } = themeHost({ + theme: { + catalog: async () => ({ + active: { themeId: "theme-a" }, + custom: ["theme-a"], + dir: directory, + }), + set: async (themeId: string) => { + setCalls.push(themeId); }, - plugins: { list: async () => ({ plugins: [] }) }, }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + plugins: { list: async () => ({ plugins: [] }) }, + }); try { const loader = createCatalogLoader(bb); @@ -403,41 +396,29 @@ describe("createCatalogLoader", () => { describe("theme watcher", () => { it("stops promptly when aborted during the initial catalog request", async () => { - let start!: (signal: AbortSignal) => Promise; let markCatalogStarted!: () => void; const catalogStarted = new Promise((resolve) => { markCatalogStarted = resolve; }); let catalogSignal: AbortSignal | undefined; - const bb = { - background: { - service( - _name: string, - options: { start(signal: AbortSignal): Promise }, - ) { - start = options.start; - }, - }, - sdk: { - theme: { - catalog: ({ signal }: { signal?: AbortSignal } = {}) => { - catalogSignal = signal; - markCatalogStarted(); - return new Promise((_resolve, reject) => { - signal?.addEventListener("abort", () => reject(signal.reason), { - once: true, - }); + const { bb, harness } = themeHost({ + theme: { + catalog: ({ signal }: { signal?: AbortSignal } = {}) => { + catalogSignal = signal; + markCatalogStarted(); + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, }); - }, + }); }, }, - rpc: { register() {} }, - log: { info() {}, warn() {} }, - } as unknown as BbPluginApi; + }); await plugin(bb); + const [service] = harness.registrations.services; const controller = new AbortController(); - const running = start(controller.signal); + const running = service!.start(controller.signal); await catalogStarted; controller.abort(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2495833ab21..19b6a52d36b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,6 +545,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../../packages/test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig @@ -704,6 +707,9 @@ importers: '@bb/server-contract': specifier: workspace:* version: link:../../packages/server-contract + '@bb/test-helpers': + specifier: workspace:* + version: link:../../packages/test-helpers '@bb/text-utils': specifier: workspace:* version: link:../../packages/text-utils @@ -1036,6 +1042,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../../packages/test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig @@ -2490,6 +2499,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -2629,6 +2641,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -2696,6 +2711,9 @@ importers: packages/secret-storage: devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -2727,6 +2745,9 @@ importers: specifier: 4.6.5 version: 4.6.5 devDependencies: + '@bb/test-helpers': + specifier: workspace:* + version: link:../test-helpers '@bb/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -2986,6 +3007,9 @@ importers: typescript-7: specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 + vitest: + specifier: ^4.1.1 + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) packages/text-utils: devDependencies: From 491d1af85098fe7c96f3ea34dbd82a5f5f4795a3 Mon Sep 17 00:00:00 2001 From: qa Date: Thu, 8 Oct 2026 23:31:14 -0700 Subject: [PATCH 03/10] Share the ThreadDetailPromptArea test harness and drop the jotai mocks The two ThreadDetailPromptArea test files declared 18 of the same mocked modules, most of the keystroke copies being weaker versions of the main file's. They now import one harness module; the main file keeps only its `usePromptDraftStorage` mock, since the keystroke tests drive the real draft storage. SecondaryPanelLayout and RootComposeSecondaryContent tests mocked jotai so every `useAtomValue` returned 40. They now render with a store that sets `secondaryPanelWidthPercentAtom` to 40, and changing that value fails the layout assertions. Co-Authored-By: Claude Opus 5.5 --- .../SecondaryPanelLayout.test.tsx | 57 +- .../thread-detail-prompt-area-harness.tsx | 742 ++++++++++++++++++ .../RootComposeSecondaryContent.test.tsx | 46 +- ...ThreadDetailPromptArea.keystrokes.test.tsx | 261 +----- .../ThreadDetailPromptArea.test.tsx | 735 +---------------- 5 files changed, 800 insertions(+), 1041 deletions(-) create mode 100644 apps/app/src/test/thread-detail-prompt-area-harness.tsx diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx index 0229d0a8fb8..fba1f64fb53 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { act, cleanup, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync"; @@ -16,6 +17,7 @@ import { type SecondaryPanelRenderArgs, } from "./SecondaryPanelLayout"; import { panelGroupState } from "@/test/react-resizable-panels-stub"; +import { secondaryPanelWidthPercentAtom } from "./threadSecondaryPanelAtoms"; type DrawerShellCallback = (open: boolean) => void; @@ -27,11 +29,6 @@ vi.mock("@/lib/browser-view-bounds-sync", () => ({ dispatchBrowserViewBoundsSync: vi.fn(), })); -vi.mock("jotai", async (importOriginal) => ({ - ...(await importOriginal()), - useAtomValue: () => 40, -})); - vi.mock( "react-resizable-panels", () => import("@/test/react-resizable-panels-stub"), @@ -118,31 +115,35 @@ function withHostedPane( function renderLayout(args: RenderLayoutArgs) { let renderArgs = args; + const store = createStore(); + store.set(secondaryPanelWidthPercentAtom, 40); const renderContent = () => withHostedPane( - - } - mainPanelId="test-main-panel" - main={
} - collapse={ - renderArgs.collapseActive === undefined - ? undefined - : { active: renderArgs.collapseActive, onToggle: noop } - } - renderPanel={renderArgs.renderPanel} - composerHost={null} - /> - , + + + } + mainPanelId="test-main-panel" + main={
} + collapse={ + renderArgs.collapseActive === undefined + ? undefined + : { active: renderArgs.collapseActive, onToggle: noop } + } + renderPanel={renderArgs.renderPanel} + composerHost={null} + /> + + , renderArgs.isFocusedHosted, ); const view = render(renderContent()); diff --git a/apps/app/src/test/thread-detail-prompt-area-harness.tsx b/apps/app/src/test/thread-detail-prompt-area-harness.tsx new file mode 100644 index 00000000000..7508a31d6b1 --- /dev/null +++ b/apps/app/src/test/thread-detail-prompt-area-harness.tsx @@ -0,0 +1,742 @@ +import type { + PendingInteraction, + PermissionMode, + PromptTextMention, + ResolvedThreadExecutionOptions, + ThreadQueuedMessage, + ThreadTimelineActivePromptMode, + ThreadTimelineGoal, +} from "@bb/domain"; +import type { + ExistingThreadExecutionInputSources, + TimelineWorkflowWorkRow, +} from "@bb/server-contract"; +import type { ReactNode } from "react"; +import { vi } from "vitest"; +import type { PromptDraftAttachment } from "@bb/client-core"; +import type { TypeaheadConfig } from "@/components/promptbox/PromptBoxInternal"; +import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; + +export const promptAreaMocks = { + cancelThreadPlanMutate: vi.fn(), + clearThreadGoalMutate: vi.fn(), + createQueuedMessageMutateAsync: vi.fn(), + createThreadMutateAsync: vi.fn(), + defaultExecutionOptions: null as ResolvedThreadExecutionOptions | null, + executionInputSources: {} as ExistingThreadExecutionInputSources, + deleteQueuedMessageMutateAsync: vi.fn(), + navigate: vi.fn(), + pluginComposerHost: null as PluginComposerHost | null, + promptDraft: { + addAttachment: vi.fn(), + attachments: [] as PromptDraftAttachment[], + clearIfCurrentMatches: vi.fn(), + getCurrent: vi.fn(), + mentions: [] as PromptTextMention[], + updateAttachments: vi.fn(), + restoreIfEmpty: vi.fn(), + setDraft: vi.fn(), + setTextAndMentions: vi.fn(), + storageKey: "bb.promptbox.contents-proj_1-thr_1-3", + subscribe: vi.fn(() => () => {}), + text: "", + }, + queuedMessages: [] as ThreadQueuedMessage[] | undefined, + reorderQueuedMessageMutateAsync: vi.fn(), + sendMessageMutateAsync: vi.fn(), + sendQueuedMessageMutateAsync: vi.fn(), + setQueuedMessageGroupBoundaryMutateAsync: vi.fn(), + stopThreadMutate: vi.fn(), + serviceTier: undefined as "default" | "fast" | undefined, + setPermissionMode: vi.fn(), + setReasoningLevel: vi.fn(), + setServiceTier: vi.fn(), + supportsServiceTier: false, + toastError: vi.fn(), + restoreThreadEnvironmentMutate: vi.fn(), + unarchiveThreadMutate: vi.fn(), + updateThreadMutate: vi.fn(), + uploadPromptAttachmentMutateAsync: vi.fn(), + updateQueuedMessageMutateAsync: vi.fn(), + useThreadDefaultExecutionOptions: vi.fn(), + useThreadCreationOptions: vi.fn(), + useThreadPromptHistory: vi.fn(), + useThreadQueuedMessages: vi.fn(), +}; + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => promptAreaMocks.navigate, + }; +}); + +vi.mock("@/components/ui/app-route-anchor", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useImmediateRouteNavigate: () => promptAreaMocks.navigate, + }; +}); + +vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { + const { ComposerBannersSlot } = await vi.importActual< + typeof import("@/components/plugin/PluginComposerBanners") + >("@/components/plugin/PluginComposerBanners"); + return { + FollowUpPromptBox: ({ + activePromptMode, + typeahead, + attachments, + composer, + environmentSummary, + execution, + executionReadOnly, + pendingInteraction, + permission, + permissionReadOnly, + pluginComposerHost, + sessionOptionsControl, + showScrollToBottomButton, + stack, + suppressPluginComposerCustomizations, + textEffects, + }: { + activePromptMode?: ThreadTimelineActivePromptMode | null; + typeahead: TypeaheadConfig; + attachments: { + items: readonly unknown[]; + onAttachFiles: (files: File[]) => void | Promise; + }; + composer: { + canModifierSubmit: boolean; + message: string; + onChangeMessage: (message: string, mentions: []) => void; + onEscape?: () => void; + onModifierSubmit: () => void; + onSubmit: () => void; + submitLabel?: string; + submitIcon?: string; + submitTitle?: string; + submitMode: { kind: string; reason?: string }; + } | null; + environmentSummary?: ReactNode; + execution: { + providerRouting: { environmentId?: string; hostId?: string }; + model: { + active?: { model: string } | null; + }; + provider: { + selectedId: string; + onChange?: (value: string) => void; + }; + handoff?: { + active: boolean; + onStart: () => void; + onExit: () => void; + onSelect: (selection: { + providerId: string; + model: string; + reasoningLevel: "medium"; + }) => void; + }; + reasoning: { value: string }; + serviceTier?: { value?: string }; + agentOptions?: { + sections: readonly { + id: string; + label: string; + selectedLabel: string; + appliesOnNextTurn: boolean; + }[]; + onChange: (optionId: string, value: string | boolean) => void; + }; + }; + executionReadOnly?: boolean; + pendingInteraction?: ReactNode; + permission: { value?: string }; + permissionReadOnly?: boolean; + pluginComposerHost?: PluginComposerHost | null; + sessionOptionsControl?: ReactNode; + showScrollToBottomButton?: boolean; + stack: ReactNode; + suppressPluginComposerCustomizations?: boolean; + textEffects?: readonly { + effect: { className: string }; + }[]; + }) => ( +
+ {environmentSummary} +
+ {pluginComposerHost ? ( + + {stack} + + ) : ( + stack + )} + {pendingInteraction} +
+
+
+ {composer === null || pendingInteraction ? "true" : "false"} +
+
+ {composer?.submitMode.kind}:{composer?.submitMode.reason ?? ""} +
+
+ {composer?.submitTitle ?? "Submit"} +
+
{composer?.submitLabel ?? ""}
+
{composer?.submitIcon ?? ""}
+
+ {suppressPluginComposerCustomizations ? "true" : "false"} +
+
{activePromptMode?.mode}
+
+ {execution.provider.selectedId} +
+
+ {execution.providerRouting.environmentId} +
+
+ {typeahead.command?.suggestions + .map((command) => command.name) + .join(",")} +
+
{execution.model.active?.model}
+
{execution.reasoning.value}
+
+ {execution.serviceTier?.value} +
+
{permission.value}
+
{sessionOptionsControl}
+
+ {(execution.agentOptions?.sections ?? []).map((section) => ( + + ))} +
+
+ {executionReadOnly ? "true" : "false"} +
+
+ {permissionReadOnly ? "true" : "false"} +
+
{attachments.items.length}
+
+ {textEffects && textEffects.length > 0 + ? textEffects.map(({ effect }) => effect.className).join(",") + : "none"} +
+
+ {showScrollToBottomButton === false ? "inline" : "bottom"} +
+
+ {pluginComposerHost + ? `${pluginComposerHost.scope.kind}:${ + pluginComposerHost.scope.kind === "queued-message" + ? pluginComposerHost.scope.queuedMessageId + : pluginComposerHost.scope.kind === "thread" + ? pluginComposerHost.scope.threadId + : (pluginComposerHost.scope.projectId ?? "null") + }` + : "route"} +
+ {pluginComposerHost ? ( + <> + + + + + ) : null} + {composer ? ( + <> + + composer.onChangeMessage(event.currentTarget.value, []) + } + /> + + {composer.canModifierSubmit ? ( + + ) : null} + {composer.onEscape ? ( + + ) : null} + + + ) : null} + {execution.provider.onChange ? ( + <> + + + + ) : null} + {execution.handoff ? ( + <> + {execution.handoff.active ? ( + + ) : null} + + + + + ) : null} +
+ ), + }; +}); + +vi.mock("@/components/promptbox/ThreadEnvironmentSummary", () => ({ + ThreadEnvironmentSummary: () => ( +
+ ), +})); + +vi.mock( + "@/components/promptbox/banner/QueuedMessagesList", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/promptbox/banner/QueuedMessagesList") + >()), + QueuedMessagesList: ({ + inlineEditor, + queuedMessages, + onEdit, + onSend, + sendAction, + sendDisabled, + }: { + inlineEditor?: { content: ReactNode; onDismiss: () => void }; + queuedMessages: readonly ThreadQueuedMessage[]; + onEdit: (request: { + queuedMessageId: string; + queuedMessageIndex: number; + }) => void; + onSend: (queuedMessageId: string) => void; + sendAction: "send-now" | "steer-when-ready"; + sendDisabled: boolean; + }) => ( +
+
{queuedMessages.length}
+ {queuedMessages.map((message, index) => ( +
+ + +
+ ))} + {inlineEditor ? ( +
+ {inlineEditor.content} + +
+ ) : null} +
+ ), + }), +); + +vi.mock("@/components/promptbox/banner/ThreadBackgroundCommandsCard", () => ({ + ThreadBackgroundCommandsCard: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadGoalCard", () => ({ + ThreadGoalCard: ({ + goal, + onClearGoal, + }: { + goal: ThreadTimelineGoal | null; + onClearGoal?: () => void; + }) => + goal ? ( +
+ Goal banner + {onClearGoal ? ( +
+ ) : null, +})); + +vi.mock("@/components/promptbox/banner/ThreadPromptContextBanner", () => ({ + ThreadPromptContextBanner: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadPromptModeCard", () => ({ + ThreadPromptModeCard: ({ + activePromptMode, + onExitPlanMode, + }: { + activePromptMode: ThreadTimelineActivePromptMode | null; + onExitPlanMode?: () => void; + }) => + activePromptMode ? ( +
+ Plan banner + {onExitPlanMode ? ( +
+ ) : null, +})); + +vi.mock("@/components/promptbox/banner/ThreadTodoCard", () => ({ + ThreadTodoCard: () => null, +})); + +vi.mock("@/components/promptbox/banner/ThreadWorkflowCard", () => { + const MockWorkflowCard = ({ + workflow, + isExpanded, + onToggle, + }: { + workflow: TimelineWorkflowWorkRow; + isExpanded: boolean; + onToggle: () => void; + }) => ( + + ); + return { + ThreadWorkflowCard: MockWorkflowCard, + ThreadWorkflowSummary: ({ + workflow, + }: { + workflow: TimelineWorkflowWorkRow; + }) => {workflow.workflowName}, + }; +}); + +vi.mock( + "@/components/thread/pending-interactions/ThreadPendingInteractionBanner", + () => ({ + ThreadPendingInteractionBanners: () => ( +
Pending interaction
+ ), + }), +); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { error: promptAreaMocks.toastError }, +})); + +vi.mock("@/hooks/useCommandSuggestions", () => ({ + useCommandSuggestions: ({ + providerId, + commandScope, + }: { + providerId: string; + commandScope: string; + }) => ({ + hasMore: false, + isError: false, + isLoading: false, + isLoadingMore: false, + loadMore: vi.fn(), + suggestions: [{ name: `${providerId}:${commandScope}` }], + triggers: [], + }), +})); + +vi.mock("@/hooks/usePromptMentions", () => ({ + usePromptMentions: () => ({ + isError: false, + isLoading: false, + setQuery: vi.fn(), + suggestions: [], + }), +})); + +vi.mock("@/hooks/useThreadCreationOptions", async () => { + const { useState } = await import("react"); + return { + useThreadCreationOptions: (options: { + initialProviderId: string; + initialPermissionMode?: PermissionMode; + }) => { + promptAreaMocks.useThreadCreationOptions(options); + const [selectedProviderId, setSelectedProviderId] = useState( + options.initialProviderId, + ); + const [explicitModel, setExplicitModel] = useState(null); + const isClaude = selectedProviderId === "claude-code"; + return { + activeModel: null, + executionInputSources: promptAreaMocks.executionInputSources, + executionOptionsRouting: { hostId: "host_1" }, + providers: [], + hasMultipleProviders: true, + isLoadingModels: false, + modelCatalogIsSettled: true, + modelLoadError: null, + modelLoadFailed: false, + modelOptions: [], + moreModelOptions: [], + permissionMode: options.initialPermissionMode ?? "auto", + permissionModeOptions: [], + providerOptions: [ + { value: "codex", label: "Codex" }, + { value: "claude-code", label: "Claude Code" }, + ], + reasoningLevel: "medium", + reasoningOptions: [], + selectedModel: explicitModel ?? (isClaude ? "claude-opus-5" : "gpt-5"), + selectedProviderComposerActions: [], + selectedProviderDisplayName: isClaude ? "Claude Code" : "Codex", + selectedProviderId, + serviceTier: promptAreaMocks.serviceTier, + serviceTierSupportByProvider: {}, + setPermissionMode: promptAreaMocks.setPermissionMode, + setReasoningLevel: promptAreaMocks.setReasoningLevel, + setProviderModelReasoning: ({ + providerId, + model, + }: { + providerId: string; + model: string; + }) => { + setSelectedProviderId(providerId); + setExplicitModel(model); + }, + setSelectedModel: setExplicitModel, + setSelectedProviderId: (providerId: string) => { + setSelectedProviderId(providerId); + setExplicitModel(null); + }, + setServiceTier: promptAreaMocks.setServiceTier, + supportsPermissionModeSelection: true, + supportsServiceTier: promptAreaMocks.supportsServiceTier, + }; + }, + }; +}); + +vi.mock("@/hooks/mutations/project-mutations", () => ({ + useUploadPromptAttachment: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.uploadPromptAttachmentMutateAsync, + }), +})); + +vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ + useCancelThreadPlan: () => ({ + isPending: false, + mutate: promptAreaMocks.cancelThreadPlanMutate, + }), + useClearThreadGoal: () => ({ + isPending: false, + mutate: promptAreaMocks.clearThreadGoalMutate, + }), + useCreateThread: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.createThreadMutateAsync, + }), + useCreateThreadQueuedMessage: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.createQueuedMessageMutateAsync, + }), + useDeleteThreadQueuedMessage: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.deleteQueuedMessageMutateAsync, + }), + useReorderThreadQueuedMessage: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.reorderQueuedMessageMutateAsync, + }), + useSetThreadQueuedMessageGroupBoundary: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.setQueuedMessageGroupBoundaryMutateAsync, + }), + useSendThreadQueuedMessage: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.sendQueuedMessageMutateAsync, + }), + useStopThread: () => ({ + isPending: false, + mutate: promptAreaMocks.stopThreadMutate, + variables: null, + }), + useUpdateThreadQueuedMessage: () => ({ + isPending: false, + mutateAsync: promptAreaMocks.updateQueuedMessageMutateAsync, + }), +})); + +vi.mock("@/hooks/mutations/thread-state-mutations", () => ({ + useRestoreThreadEnvironment: () => ({ + isPending: false, + mutate: promptAreaMocks.restoreThreadEnvironmentMutate, + variables: null, + }), + useUnarchiveThread: () => ({ + isPending: false, + mutate: promptAreaMocks.unarchiveThreadMutate, + variables: null, + }), + useUpdateThread: () => ({ + isPending: false, + mutate: promptAreaMocks.updateThreadMutate, + }), +})); + +vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ + useProjectDisplayName: () => null, +})); + +vi.mock("@/hooks/queries/thread-default-execution-options-query", () => ({ + useThreadDefaultExecutionOptions: (threadId: string, options: unknown) => { + promptAreaMocks.useThreadDefaultExecutionOptions(threadId, options); + return { + data: promptAreaMocks.defaultExecutionOptions, + isError: false, + }; + }, +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + orderPendingInteractions: ( + interactions: readonly PendingInteraction[] | undefined, + ) => interactions ?? [], + useThreadPromptHistory: (threadId: string, options: unknown) => { + promptAreaMocks.useThreadPromptHistory(threadId, options); + return { data: [] }; + }, + useThreadQueuedMessages: (threadId: string, options: unknown) => { + promptAreaMocks.useThreadQueuedMessages(threadId, options); + return { data: promptAreaMocks.queuedMessages }; + }, +})); diff --git a/apps/app/src/views/RootComposeSecondaryContent.test.tsx b/apps/app/src/views/RootComposeSecondaryContent.test.tsx index 6c853c7068c..f786082d2f9 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.test.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.test.tsx @@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from "react"; import { act, cleanup, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { @@ -13,6 +14,7 @@ import { RootComposeSecondaryContent, } from "./RootComposeSecondaryContent"; import { panelGroupState } from "@/test/react-resizable-panels-stub"; +import { secondaryPanelWidthPercentAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms"; type RootComposeSecondaryContentProps = ComponentProps< typeof RootComposeSecondaryContent @@ -38,11 +40,6 @@ function clearDesktopChrome(): void { delete (window as unknown as TestDesktopWindow).bbDesktop; } -vi.mock("jotai", async (importOriginal) => ({ - ...(await importOriginal()), - useAtomValue: () => 40, -})); - vi.mock( "react-resizable-panels", () => import("@/test/react-resizable-panels-stub"), @@ -151,28 +148,11 @@ function withPaneContext( function renderRootCompose(args: RenderRootComposeArgs) { let renderArgs = args; - const content = ( - - undefined} - secondaryPanel={createSecondaryPanel(renderArgs.isSecondaryPanelOpen)} - > -
- - - ); - const view = render(withPaneContext(content, renderArgs.isTopRow)); - - return { - ...view, - rerenderWith(nextArgs: Partial) { - renderArgs = { ...renderArgs, ...nextArgs }; - const nextContent = ( + const store = createStore(); + store.set(secondaryPanelWidthPercentAtom, 40); + const renderContent = () => + withPaneContext( + @@ -188,8 +168,16 @@ function renderRootCompose(args: RenderRootComposeArgs) {
- ); - view.rerender(withPaneContext(nextContent, renderArgs.isTopRow)); + , + renderArgs.isTopRow, + ); + const view = render(renderContent()); + + return { + ...view, + rerenderWith(nextArgs: Partial) { + renderArgs = { ...renderArgs, ...nextArgs }; + view.rerender(renderContent()); }, }; } diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index adcb306bee2..db768a82542 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { promptAreaMocks as mocks } from "@/test/thread-detail-prompt-area-harness"; import type { PendingInteraction, ThreadQueuedMessage, @@ -17,7 +18,6 @@ import { screen, within, } from "@testing-library/react"; -import type { ReactNode } from "react"; import { afterEach, beforeAll, @@ -36,252 +36,7 @@ import { LazyQueuedMessagesList } from "@/components/promptbox/banner/LazyQueued import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea"; -const mocks = vi.hoisted(() => ({ - sendMessageMutateAsync: vi.fn(), - shellProbeRenders: vi.fn(), - updateQueuedMessageMutateAsync: vi.fn(), -})); - -vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({ - FollowUpPromptBox: ({ - composer, - pendingInteraction = null, - stack, - }: { - composer: { - message: string; - onChangeMessage: (message: string, mentions: []) => void; - onSubmit: () => void; - } | null; - pendingInteraction?: ReactNode; - stack: ReactNode; - }) => ( -
-
- {stack} - {pendingInteraction} -
- {composer ? ( - - ) : null} -
- ), -})); - -vi.mock("@/components/promptbox/ThreadEnvironmentSummary", () => ({ - ThreadEnvironmentSummary: () =>
, -})); - -vi.mock("@/components/promptbox/banner/QueuedMessagesList", () => ({ - QueuedMessagesList: ({ - inlineEditor, - queuedMessages, - onEdit, - }: { - inlineEditor?: { content: ReactNode; onDismiss: () => void }; - queuedMessages: readonly ThreadQueuedMessage[]; - onEdit: (request: { - queuedMessageId: string; - queuedMessageIndex: number; - }) => void; - }) => ( -
- {queuedMessages.map((message, index) => ( - - ))} - {inlineEditor ? ( -
- {inlineEditor.content} - -
- ) : null} -
- ), -})); - -vi.mock("@/components/promptbox/banner/ThreadBackgroundCommandsCard", () => ({ - ThreadBackgroundCommandsCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadGoalCard", () => ({ - ThreadGoalCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadPromptContextBanner", () => ({ - ThreadPromptContextBanner: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadPromptModeCard", () => ({ - ThreadPromptModeCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadTodoCard", () => ({ - ThreadTodoCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadWorkflowCard", () => ({ - ThreadWorkflowCard: () => null, -})); - -vi.mock( - "@/components/thread/pending-interactions/ThreadPendingInteractionBanner", - () => ({ - ThreadPendingInteractionBanners: () => ( -
- ), - }), -); - -vi.mock("@/components/ui/app-toast", () => ({ - appToast: { error: vi.fn() }, -})); - -vi.mock("@/hooks/useCommandSuggestions", () => ({ - useCommandSuggestions: () => ({ - hasMore: false, - isError: false, - isLoading: false, - isLoadingMore: false, - loadMore: vi.fn(), - suggestions: [], - triggers: [], - }), -})); - -vi.mock("@/hooks/usePromptMentions", () => ({ - usePromptMentions: () => ({ - isError: false, - isLoading: false, - setQuery: vi.fn(), - suggestions: [], - }), -})); - -vi.mock("@/hooks/useThreadCreationOptions", () => ({ - useThreadCreationOptions: () => ({ - activeModel: null, - executionInputSources: {}, - hasMultipleProviders: false, - isLoadingModels: false, - modelLoadError: null, - modelLoadFailed: false, - modelOptions: [], - moreModelOptions: [], - permissionMode: "auto", - permissionModeOptions: [], - providers: [], - providerOptions: [], - reasoningLevel: "medium", - reasoningOptions: [], - selectedModel: "gpt-5", - selectedProviderComposerActions: [], - selectedProviderDisplayName: "Codex", - selectedProviderId: "codex", - serviceTier: undefined, - serviceTierSupportByProvider: {}, - setPermissionMode: vi.fn(), - setReasoningLevel: vi.fn(), - setSelectedModel: vi.fn(), - setServiceTier: vi.fn(), - supportsPermissionModeSelection: true, - supportsServiceTier: false, - }), -})); - -vi.mock("@/hooks/mutations/project-mutations", () => ({ - useUploadPromptAttachment: () => ({ - isPending: false, - mutateAsync: vi.fn(), - }), -})); - -vi.mock("@/hooks/mutations/thread-runtime-mutations", () => { - const idleMutation = () => ({ - isPending: false, - mutate: vi.fn(), - mutateAsync: vi.fn(), - variables: null, - }); - return { - useCancelThreadPlan: idleMutation, - useClearThreadGoal: idleMutation, - useCreateThread: idleMutation, - useCreateThreadQueuedMessage: idleMutation, - useDeleteThreadQueuedMessage: idleMutation, - useReorderThreadQueuedMessage: idleMutation, - useSetThreadQueuedMessageGroupBoundary: idleMutation, - useSendThreadQueuedMessage: idleMutation, - useStopThread: idleMutation, - useUpdateThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.updateQueuedMessageMutateAsync, - }), - }; -}); - -vi.mock("@/hooks/mutations/thread-state-mutations", () => ({ - useRestoreThreadEnvironment: () => ({ - isPending: false, - mutate: vi.fn(), - variables: null, - }), - useUnarchiveThread: () => ({ - isPending: false, - mutate: vi.fn(), - variables: null, - }), - useUpdateThread: () => ({ isPending: false, mutate: vi.fn() }), -})); - -vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useProjectDisplayName: () => null, -})); - -vi.mock("@/hooks/queries/thread-default-execution-options-query", () => ({ - useThreadDefaultExecutionOptions: () => ({ - data: { - model: "gpt-5", - permissionMode: "auto", - reasoningLevel: "medium", - serviceTier: "default", - source: "client/turn/requested", - }, - isError: false, - }), -})); - -const queryMocks = vi.hoisted(() => ({ - queuedMessages: [] as ThreadQueuedMessage[], -})); - -vi.mock("@/hooks/queries/thread-queries", () => ({ - orderPendingInteractions: ( - interactions: readonly PendingInteraction[] | undefined, - ) => interactions ?? [], - useThreadPromptHistory: () => ({ data: [] }), - useThreadQueuedMessages: () => ({ data: queryMocks.queuedMessages }), -})); +const shellProbeRenders = vi.fn(); const PROJECT_ID = "proj_keystrokes"; @@ -338,7 +93,7 @@ function makePendingInteraction(threadId: string): PendingInteraction { } function ShellProbe() { - mocks.shellProbeRenders(usePluginComposerHost()); + shellProbeRenders(usePluginComposerHost()); return null; } @@ -349,11 +104,11 @@ function PublishedHostDraftProbe() { } function observedShellHosts(): readonly unknown[] { - return mocks.shellProbeRenders.mock.calls.map((call) => call[0]); + return shellProbeRenders.mock.calls.map((call) => call[0]); } function shellRenderCount(): number { - return mocks.shellProbeRenders.mock.calls.length; + return shellProbeRenders.mock.calls.length; } interface RenderPromptAreaArgs { @@ -427,7 +182,7 @@ beforeAll(() => LazyQueuedMessagesList.preload()); beforeEach(() => { threadCounter += 1; threadId = `thr_keystrokes_${threadCounter}`; - queryMocks.queuedMessages = []; + mocks.queuedMessages = []; mocks.sendMessageMutateAsync.mockResolvedValue(undefined); mocks.updateQueuedMessageMutateAsync.mockResolvedValue(undefined); }); @@ -491,7 +246,7 @@ describe("ThreadDetailPromptArea published composer host", () => { thread: makeThread(threadId), pendingInteractions: [makePendingInteraction(threadId)], }); - expect(screen.getByTestId("pending-interaction")).toBeTruthy(); + expect(screen.getByText("Pending interaction")).toBeTruthy(); expect(screen.getByTestId("published-host-draft").textContent).toBe(""); const rendersAfterMount = shellRenderCount(); @@ -520,7 +275,7 @@ describe("ThreadDetailPromptArea published composer host", () => { }); it("swaps to a per-session stable host for inline queued-message edits and streams the inline draft", () => { - queryMocks.queuedMessages = [makeQueuedMessage()]; + mocks.queuedMessages = [makeQueuedMessage()]; renderPromptArea({ thread: makeThread(threadId) }); const rendersAfterMount = shellRenderCount(); const threadHost = observedShellHosts().at(-1); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 096e6b86532..e9aa6c274e2 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // @vitest-environment jsdom +import { promptAreaMocks as mocks } from "@/test/thread-detail-prompt-area-harness"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + import type { PendingInteraction, - PermissionMode, PromptTextMention, - ResolvedThreadExecutionOptions, ThreadQueuedMessage, ThreadTimelineActivePromptMode, ThreadTimelineGoal, @@ -22,7 +22,6 @@ import { within, } from "@testing-library/react"; import type { - ExistingThreadExecutionInputSources, TimelineWorkflowWorkRow, ThreadTimelineSessionOption, } from "@bb/server-contract"; @@ -31,7 +30,7 @@ import { makeThreadQueuedMessage as makeThreadQueuedMessageFixture, makeThreadWithRuntime as makeThreadWithRuntimeFixture, } from "@bb/test-helpers/domain-fixtures"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps } from "react"; import { afterEach, beforeAll, @@ -45,8 +44,6 @@ import { LazyQueuedMessagesList } from "@/components/promptbox/banner/LazyQueued import { workflowRow } from "@/test/fixtures/thread-timeline-rows"; import type { PromptDraftAttachment } from "@bb/client-core"; import { BbHttpError } from "@/lib/sdk"; -import type { TypeaheadConfig } from "@/components/promptbox/PromptBoxInternal"; -import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; import type { ExperimentalComposerSelection } from "@get-bb/plugin-sdk"; import { setComposerTextEffect } from "@/lib/composer-text-effects"; import { @@ -60,734 +57,10 @@ import { } from "./ThreadDetailPromptArea"; import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; -const mocks = vi.hoisted(() => ({ - cancelThreadPlanMutate: vi.fn(), - clearThreadGoalMutate: vi.fn(), - createQueuedMessageMutateAsync: vi.fn(), - createThreadMutateAsync: vi.fn(), - defaultExecutionOptions: null as ResolvedThreadExecutionOptions | null, - executionInputSources: {} as ExistingThreadExecutionInputSources, - deleteQueuedMessageMutateAsync: vi.fn(), - navigate: vi.fn(), - pluginComposerHost: null as PluginComposerHost | null, - promptDraft: { - addAttachment: vi.fn(), - attachments: [] as PromptDraftAttachment[], - clearIfCurrentMatches: vi.fn(), - getCurrent: vi.fn(), - mentions: [] as PromptTextMention[], - updateAttachments: vi.fn(), - restoreIfEmpty: vi.fn(), - setDraft: vi.fn(), - setTextAndMentions: vi.fn(), - storageKey: "bb.promptbox.contents-proj_1-thr_1-3", - subscribe: vi.fn(() => () => {}), - text: "", - }, - queuedMessages: [] as ThreadQueuedMessage[] | undefined, - reorderQueuedMessageMutateAsync: vi.fn(), - sendMessageMutateAsync: vi.fn(), - sendQueuedMessageMutateAsync: vi.fn(), - setQueuedMessageGroupBoundaryMutateAsync: vi.fn(), - stopThreadMutate: vi.fn(), - serviceTier: undefined as "default" | "fast" | undefined, - setPermissionMode: vi.fn(), - setReasoningLevel: vi.fn(), - setServiceTier: vi.fn(), - supportsServiceTier: false, - toastError: vi.fn(), - restoreThreadEnvironmentMutate: vi.fn(), - unarchiveThreadMutate: vi.fn(), - updateThreadMutate: vi.fn(), - uploadPromptAttachmentMutateAsync: vi.fn(), - updateQueuedMessageMutateAsync: vi.fn(), - useThreadDefaultExecutionOptions: vi.fn(), - useThreadCreationOptions: vi.fn(), - useThreadPromptHistory: vi.fn(), - useThreadQueuedMessages: vi.fn(), -})); - -vi.mock("react-router-dom", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useNavigate: () => mocks.navigate, - }; -}); - -vi.mock("@/components/ui/app-route-anchor", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - useImmediateRouteNavigate: () => mocks.navigate, - }; -}); - -vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { - const { ComposerBannersSlot } = await vi.importActual< - typeof import("@/components/plugin/PluginComposerBanners") - >("@/components/plugin/PluginComposerBanners"); - return { - FollowUpPromptBox: ({ - activePromptMode, - typeahead, - attachments, - composer, - environmentSummary, - execution, - executionReadOnly, - pendingInteraction, - permission, - permissionReadOnly, - pluginComposerHost, - sessionOptionsControl, - showScrollToBottomButton, - stack, - suppressPluginComposerCustomizations, - textEffects, - }: { - activePromptMode?: ThreadTimelineActivePromptMode | null; - typeahead: TypeaheadConfig; - attachments: { - items: readonly unknown[]; - onAttachFiles: (files: File[]) => void | Promise; - }; - composer: { - canModifierSubmit: boolean; - message: string; - onChangeMessage: (message: string, mentions: []) => void; - onEscape?: () => void; - onModifierSubmit: () => void; - onSubmit: () => void; - submitLabel?: string; - submitIcon?: string; - submitTitle?: string; - submitMode: { kind: string; reason?: string }; - } | null; - environmentSummary?: ReactNode; - execution: { - providerRouting: { environmentId?: string; hostId?: string }; - model: { - active?: { model: string } | null; - }; - provider: { - selectedId: string; - onChange?: (value: string) => void; - }; - handoff?: { - active: boolean; - onStart: () => void; - onExit: () => void; - onSelect: (selection: { - providerId: string; - model: string; - reasoningLevel: "medium"; - }) => void; - }; - reasoning: { value: string }; - serviceTier?: { value?: string }; - agentOptions?: { - sections: readonly { - id: string; - label: string; - selectedLabel: string; - appliesOnNextTurn: boolean; - }[]; - onChange: (optionId: string, value: string | boolean) => void; - }; - }; - executionReadOnly?: boolean; - pendingInteraction?: ReactNode; - permission: { value?: string }; - permissionReadOnly?: boolean; - pluginComposerHost?: PluginComposerHost | null; - sessionOptionsControl?: ReactNode; - showScrollToBottomButton?: boolean; - stack: ReactNode; - suppressPluginComposerCustomizations?: boolean; - textEffects?: readonly { - effect: { className: string }; - }[]; - }) => ( -
- {environmentSummary} -
- {pluginComposerHost ? ( - - {stack} - - ) : ( - stack - )} - {pendingInteraction} -
-
-
- {composer === null || pendingInteraction ? "true" : "false"} -
-
- {composer?.submitMode.kind}:{composer?.submitMode.reason ?? ""} -
-
- {composer?.submitTitle ?? "Submit"} -
-
{composer?.submitLabel ?? ""}
-
{composer?.submitIcon ?? ""}
-
- {suppressPluginComposerCustomizations ? "true" : "false"} -
-
{activePromptMode?.mode}
-
- {execution.provider.selectedId} -
-
- {execution.providerRouting.environmentId} -
-
- {typeahead.command?.suggestions - .map((command) => command.name) - .join(",")} -
-
{execution.model.active?.model}
-
{execution.reasoning.value}
-
- {execution.serviceTier?.value} -
-
{permission.value}
-
{sessionOptionsControl}
-
- {(execution.agentOptions?.sections ?? []).map((section) => ( - - ))} -
-
- {executionReadOnly ? "true" : "false"} -
-
- {permissionReadOnly ? "true" : "false"} -
-
{attachments.items.length}
-
- {textEffects && textEffects.length > 0 - ? textEffects.map(({ effect }) => effect.className).join(",") - : "none"} -
-
- {showScrollToBottomButton === false ? "inline" : "bottom"} -
-
- {pluginComposerHost - ? `${pluginComposerHost.scope.kind}:${ - pluginComposerHost.scope.kind === "queued-message" - ? pluginComposerHost.scope.queuedMessageId - : pluginComposerHost.scope.kind === "thread" - ? pluginComposerHost.scope.threadId - : (pluginComposerHost.scope.projectId ?? "null") - }` - : "route"} -
- {pluginComposerHost ? ( - <> - - - - - ) : null} - {composer ? ( - <> - - composer.onChangeMessage(event.currentTarget.value, []) - } - /> - - {composer.canModifierSubmit ? ( - - ) : null} - {composer.onEscape ? ( - - ) : null} - - - ) : null} - {execution.provider.onChange ? ( - <> - - - - ) : null} - {execution.handoff ? ( - <> - {execution.handoff.active ? ( - - ) : null} - - - - - ) : null} -
- ), - }; -}); - -vi.mock("@/components/promptbox/ThreadEnvironmentSummary", () => ({ - ThreadEnvironmentSummary: () => ( -
- ), -})); - -vi.mock( - "@/components/promptbox/banner/QueuedMessagesList", - async (importOriginal) => ({ - ...(await importOriginal< - typeof import("@/components/promptbox/banner/QueuedMessagesList") - >()), - QueuedMessagesList: ({ - inlineEditor, - queuedMessages, - onEdit, - onSend, - sendAction, - sendDisabled, - }: { - inlineEditor?: { content: ReactNode; onDismiss: () => void }; - queuedMessages: readonly ThreadQueuedMessage[]; - onEdit: (request: { - queuedMessageId: string; - queuedMessageIndex: number; - }) => void; - onSend: (queuedMessageId: string) => void; - sendAction: "send-now" | "steer-when-ready"; - sendDisabled: boolean; - }) => ( -
-
{queuedMessages.length}
- {queuedMessages.map((message, index) => ( -
- - -
- ))} - {inlineEditor ? ( -
- {inlineEditor.content} - -
- ) : null} -
- ), - }), -); - -vi.mock("@/components/promptbox/banner/ThreadBackgroundCommandsCard", () => ({ - ThreadBackgroundCommandsCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadGoalCard", () => ({ - ThreadGoalCard: ({ - goal, - onClearGoal, - }: { - goal: ThreadTimelineGoal | null; - onClearGoal?: () => void; - }) => - goal ? ( -
- Goal banner - {onClearGoal ? ( -
- ) : null, -})); - -vi.mock("@/components/promptbox/banner/ThreadPromptContextBanner", () => ({ - ThreadPromptContextBanner: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadPromptModeCard", () => ({ - ThreadPromptModeCard: ({ - activePromptMode, - onExitPlanMode, - }: { - activePromptMode: ThreadTimelineActivePromptMode | null; - onExitPlanMode?: () => void; - }) => - activePromptMode ? ( -
- Plan banner - {onExitPlanMode ? ( -
- ) : null, -})); - -vi.mock("@/components/promptbox/banner/ThreadTodoCard", () => ({ - ThreadTodoCard: () => null, -})); - -vi.mock("@/components/promptbox/banner/ThreadWorkflowCard", () => { - const MockWorkflowCard = ({ - workflow, - isExpanded, - onToggle, - }: { - workflow: TimelineWorkflowWorkRow; - isExpanded: boolean; - onToggle: () => void; - }) => ( - - ); - return { - ThreadWorkflowCard: MockWorkflowCard, - ThreadWorkflowSummary: ({ - workflow, - }: { - workflow: TimelineWorkflowWorkRow; - }) => {workflow.workflowName}, - }; -}); - -vi.mock( - "@/components/thread/pending-interactions/ThreadPendingInteractionBanner", - () => ({ - ThreadPendingInteractionBanners: () => ( -
Pending interaction
- ), - }), -); - -vi.mock("@/components/ui/app-toast", () => ({ - appToast: { error: mocks.toastError }, -})); - -vi.mock("@/hooks/useCommandSuggestions", () => ({ - useCommandSuggestions: ({ - providerId, - commandScope, - }: { - providerId: string; - commandScope: string; - }) => ({ - hasMore: false, - isError: false, - isLoading: false, - isLoadingMore: false, - loadMore: vi.fn(), - suggestions: [{ name: `${providerId}:${commandScope}` }], - triggers: [], - }), -})); - vi.mock("@/hooks/usePromptDraftStorage", () => ({ usePromptDraftStorage: () => mocks.promptDraft, })); -vi.mock("@/hooks/usePromptMentions", () => ({ - usePromptMentions: () => ({ - isError: false, - isLoading: false, - setQuery: vi.fn(), - suggestions: [], - }), -})); - -vi.mock("@/hooks/useThreadCreationOptions", async () => { - const { useState } = await import("react"); - return { - useThreadCreationOptions: (options: { - initialProviderId: string; - initialPermissionMode?: PermissionMode; - }) => { - mocks.useThreadCreationOptions(options); - const [selectedProviderId, setSelectedProviderId] = useState( - options.initialProviderId, - ); - const [explicitModel, setExplicitModel] = useState(null); - const isClaude = selectedProviderId === "claude-code"; - return { - activeModel: null, - executionInputSources: mocks.executionInputSources, - executionOptionsRouting: { hostId: "host_1" }, - providers: [], - hasMultipleProviders: true, - isLoadingModels: false, - modelCatalogIsSettled: true, - modelLoadError: null, - modelLoadFailed: false, - modelOptions: [], - moreModelOptions: [], - permissionMode: options.initialPermissionMode ?? "auto", - permissionModeOptions: [], - providerOptions: [ - { value: "codex", label: "Codex" }, - { value: "claude-code", label: "Claude Code" }, - ], - reasoningLevel: "medium", - reasoningOptions: [], - selectedModel: explicitModel ?? (isClaude ? "claude-opus-5" : "gpt-5"), - selectedProviderComposerActions: [], - selectedProviderDisplayName: isClaude ? "Claude Code" : "Codex", - selectedProviderId, - serviceTier: mocks.serviceTier, - serviceTierSupportByProvider: {}, - setPermissionMode: mocks.setPermissionMode, - setReasoningLevel: mocks.setReasoningLevel, - setProviderModelReasoning: ({ - providerId, - model, - }: { - providerId: string; - model: string; - }) => { - setSelectedProviderId(providerId); - setExplicitModel(model); - }, - setSelectedModel: setExplicitModel, - setSelectedProviderId: (providerId: string) => { - setSelectedProviderId(providerId); - setExplicitModel(null); - }, - setServiceTier: mocks.setServiceTier, - supportsPermissionModeSelection: true, - supportsServiceTier: mocks.supportsServiceTier, - }; - }, - }; -}); - -vi.mock("@/hooks/mutations/project-mutations", () => ({ - useUploadPromptAttachment: () => ({ - isPending: false, - mutateAsync: mocks.uploadPromptAttachmentMutateAsync, - }), -})); - -vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ - useCancelThreadPlan: () => ({ - isPending: false, - mutate: mocks.cancelThreadPlanMutate, - }), - useClearThreadGoal: () => ({ - isPending: false, - mutate: mocks.clearThreadGoalMutate, - }), - useCreateThread: () => ({ - isPending: false, - mutateAsync: mocks.createThreadMutateAsync, - }), - useCreateThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.createQueuedMessageMutateAsync, - }), - useDeleteThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.deleteQueuedMessageMutateAsync, - }), - useReorderThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.reorderQueuedMessageMutateAsync, - }), - useSetThreadQueuedMessageGroupBoundary: () => ({ - isPending: false, - mutateAsync: mocks.setQueuedMessageGroupBoundaryMutateAsync, - }), - useSendThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.sendQueuedMessageMutateAsync, - }), - useStopThread: () => ({ - isPending: false, - mutate: mocks.stopThreadMutate, - variables: null, - }), - useUpdateThreadQueuedMessage: () => ({ - isPending: false, - mutateAsync: mocks.updateQueuedMessageMutateAsync, - }), -})); - -vi.mock("@/hooks/mutations/thread-state-mutations", () => ({ - useRestoreThreadEnvironment: () => ({ - isPending: false, - mutate: mocks.restoreThreadEnvironmentMutate, - variables: null, - }), - useUnarchiveThread: () => ({ - isPending: false, - mutate: mocks.unarchiveThreadMutate, - variables: null, - }), - useUpdateThread: () => ({ - isPending: false, - mutate: mocks.updateThreadMutate, - }), -})); - -vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useProjectDisplayName: () => null, -})); - -vi.mock("@/hooks/queries/thread-default-execution-options-query", () => ({ - useThreadDefaultExecutionOptions: (threadId: string, options: unknown) => { - mocks.useThreadDefaultExecutionOptions(threadId, options); - return { - data: mocks.defaultExecutionOptions, - isError: false, - }; - }, -})); - -vi.mock("@/hooks/queries/thread-queries", () => ({ - orderPendingInteractions: ( - interactions: readonly PendingInteraction[] | undefined, - ) => interactions ?? [], - useThreadPromptHistory: (threadId: string, options: unknown) => { - mocks.useThreadPromptHistory(threadId, options); - return { data: [] }; - }, - useThreadQueuedMessages: (threadId: string, options: unknown) => { - mocks.useThreadQueuedMessages(threadId, options); - return { data: mocks.queuedMessages }; - }, -})); - function makeQueuedMessage( overrides: Partial = {}, ): ThreadQueuedMessage { From 781a5265c07122020d3aa51c4583a43537b34584 Mon Sep 17 00:00:00 2001 From: qa Date: Thu, 8 Oct 2026 23:31:47 -0700 Subject: [PATCH 04/10] Delete tests that restate implementation or pin prose Each removal was checked against the test-audit skill: - create-via-prompt-examples: built its expected values with the same `BROWSE_ARCHETYPES.map(briefPrompt)` as the code; `getCreateExamples` loses its test-only export. - MachineLabel: covered only the persistent-host branch. It now covers an ephemeral machine whose provider presentation is not loaded. - plugin-authoring-docs (4) and submit-a-plugin (4): exact guide prose. The completeness checks stay, and so does the submit-a-plugin release-safety test (lifecycle scripts off, publish only after approval), which came from a security review. - SecondaryPanelTabStrip and UpdatesSettingsSection: colour, border, radius, padding and font-weight class assertions. Visibility, layout, touch-target and reduced-motion assertions stay, as does the changelog footer's foreground/background pair, which keeps its link legible. Mutation checks kept route-loading-skeleton: it is the only guard on the standalone bleed margins. Co-Authored-By: Claude Opus 5.5 --- .../create-via-prompt-examples.test.ts | 19 ----- .../components/create-via-prompt-examples.tsx | 2 +- .../components/machines/MachineLabel.test.tsx | 23 +++++- .../SecondaryPanelTabStrip.test.ts | 10 --- .../settings/UpdatesSettingsSection.test.tsx | 14 ---- .../plugins/plugin-authoring-docs.test.ts | 79 ------------------- .../test/skills/submit-a-plugin.test.ts | 63 --------------- 7 files changed, 20 insertions(+), 190 deletions(-) delete mode 100644 apps/app/src/components/create-via-prompt-examples.test.ts diff --git a/apps/app/src/components/create-via-prompt-examples.test.ts b/apps/app/src/components/create-via-prompt-examples.test.ts deleted file mode 100644 index f5d9877f4d4..00000000000 --- a/apps/app/src/components/create-via-prompt-examples.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - BROWSE_ARCHETYPES, - briefPrompt, -} from "@/components/plugin/browse-hero/browse-hero-archetypes"; -import { getCreateExamples } from "./create-via-prompt-examples"; - -describe("getCreateExamples", () => { - it("serves the Browse archetypes as the plugin templates, one source", () => { - const { examples } = getCreateExamples("plugin"); - - expect(examples.map((example) => example.label)).toEqual( - BROWSE_ARCHETYPES.map((archetype) => archetype.title), - ); - for (const [index, example] of examples.entries()) { - expect(example.prompt).toBe(briefPrompt(BROWSE_ARCHETYPES[index]!)); - } - }); -}); diff --git a/apps/app/src/components/create-via-prompt-examples.tsx b/apps/app/src/components/create-via-prompt-examples.tsx index befe8ac61c6..3274fc8a56e 100644 --- a/apps/app/src/components/create-via-prompt-examples.tsx +++ b/apps/app/src/components/create-via-prompt-examples.tsx @@ -67,7 +67,7 @@ interface CreateExample { prompt: string; } -export function getCreateExamples(kind: CreateViaPromptKind): { +function getCreateExamples(kind: CreateViaPromptKind): { examples: CreateExample[]; } { const config = CONFIG[kind]; diff --git a/apps/app/src/components/machines/MachineLabel.test.tsx b/apps/app/src/components/machines/MachineLabel.test.tsx index 112048f8433..fd87123778a 100644 --- a/apps/app/src/components/machines/MachineLabel.test.tsx +++ b/apps/app/src/components/machines/MachineLabel.test.tsx @@ -6,13 +6,28 @@ import { describe, expect, it } from "vitest"; import { MachineLabel } from "./MachineLabel"; describe("MachineLabel", () => { - it("uses the laptop icon and host name for a persistent machine", () => { + it("shows a provider fallback, not the laptop glyph or another provider's icon, when an ephemeral machine's provider is not loaded", () => { const { container } = render( - , + , ); - expect(screen.getByText("MacBook Pro")).toBeTruthy(); - expect(container.querySelector('[data-icon="Laptop"]')).not.toBeNull(); + const icon = container.querySelector("[data-icon]"); + expect(screen.getByText("Sandbox abc123")).toBeTruthy(); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("data-icon")).not.toBe("Laptop"); + expect(icon?.getAttribute("data-icon")).not.toBe("Cloud"); }); it("uses a registered icon for an ephemeral machine whose provider is not loaded", () => { diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts index 165fb7d389e..69b8e07b4b4 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts @@ -164,11 +164,6 @@ describe("secondary panel tab-strip edge fades", () => { expect(observed).toContain(content); expect(resizeCallback).toBeDefined(); expect(container.querySelectorAll("[data-overflow-fade]")).toHaveLength(2); - expect( - container - .querySelector("[data-overflow-fade='left']") - ?.classList.contains("w-6"), - ).toBe(true); const leftButton = container.querySelector( '[aria-label="Scroll tabs left"]', ); @@ -212,11 +207,6 @@ describe("secondary panel tab-strip edge fades", () => { expect(leftButton?.tabIndex).toBe(-1); expect(rightButton?.classList.contains("opacity-100")).toBe(true); expect(rightButton?.tabIndex).toBe(0); - expect(rightButton?.classList.contains("bg-sidebar")).toBe(true); - expect( - rightButton?.classList.contains("hover:bg-surface-raised-solid"), - ).toBe(true); - expect(rightButton?.classList.contains("hover:bg-state-hover")).toBe(false); const scrollBy = vi.fn(); Object.defineProperty(viewport!, "scrollBy", { diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx index 49a1b3b2e0d..6ab5435151c 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx @@ -557,21 +557,13 @@ The canonical release summary. expect( changelog?.querySelector('[data-changelog-version="9.9.9"]'), ).toBeNull(); - const changelogLabel = changelog?.querySelector("[data-changelog-label]"); - expect(changelogLabel?.className).toContain("rounded-sm"); - expect(changelogLabel?.className).not.toContain("rounded-full"); - expect(changelogLabel?.className).toContain("bg-muted/40"); const changelogPreview = changelog?.querySelector( "[data-changelog-preview]", ); - expect(changelogPreview?.className).toContain("p-4"); expect(changelogPreview?.className).not.toContain("grid"); expect( changelog?.querySelector("[data-changelog-release-scroll]")?.className, ).toContain("max-h-56"); - expect( - changelog?.querySelector("[data-changelog-footer]")?.className, - ).toContain("border-t"); expect( changelog?.querySelector("[data-changelog-footer]")?.className, ).toContain("bg-foreground"); @@ -579,11 +571,6 @@ The canonical release summary. changelog?.querySelector("[data-changelog-footer]")?.className, ).toContain("text-background"); expect(changelog?.textContent).toContain("Full changelog"); - expect( - screen.getByRole("button", { - name: "Open the full bb 9.9.9 changelog", - }).className, - ).toContain("font-semibold"); for (const highlight of ["New features", "Fixes"]) { expect( within(changelog as HTMLElement).getByRole("heading", { @@ -600,7 +587,6 @@ The canonical release summary. const changelogHeader = changelog?.querySelector("[data-changelog-header]"); const changelogCard = changelogHeader?.closest("section"); expect(changelogPreview?.firstElementChild).toBe(changelogHeader); - expect(changelogHeader?.className).not.toContain("border-b"); expect(changelogHeader?.contains(dismissChangelog)).toBe(true); expect(changelogCard?.contains(changelogPreview ?? null)).toBe(true); expect(dismissChangelog.querySelector('[data-icon="X"]')).not.toBeNull(); diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index 365f83c376e..1d46f9cff0a 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -617,16 +617,6 @@ describe("bb-plugin-authoring skill", () => { } }); - it("warns that environment provider inputs are persisted configuration, not credentials", () => { - const documented = readReference("backend-events.md").replace(/\s+/g, " "); - expect(documented).toContain( - "Parsed inputs are persisted on the environment and are readable by every plugin through the SDK, including after the environment is destroyed.", - ); - expect(documented).toContain( - "They are configuration, not a credential store; keep credentials in secret settings.", - ); - }); - it("accounts for every @get-bb/plugin-sdk/app type export", () => { for (const name of FRONTEND_TYPE_EXPORT_NAMES) { expect(skill, `${name} is not documented in the skill`).toContain(name); @@ -645,24 +635,6 @@ describe("bb-plugin-authoring skill", () => { } }); - it("keeps fake-host and distribution examples aligned with implementation", () => { - const testing = readReference("testing.md"); - const quickstart = readReference("quickstart.md"); - const distribution = readReference("distribution.md"); - - expect(testing).toContain( - "experimental_callHostRpc: async ({ method, input, hostId, signal })", - ); - expect(testing).toContain('const body = JSON.stringify({ event: "test" })'); - expect(testing).toMatch( - /experimental_emitHostSignal\(\s*"host-test",\s*"changed",\s*\{\s*reason: "test",?\s*\}/, - ); - expect(testing).not.toContain("resolveAgentConfiguration(context)"); - expect(quickstart).toContain("server.js.map"); - expect(quickstart).toContain("--omit=dev --omit=optional"); - expect(distribution).not.toMatch(/"engines"\s*:/); - }); - it("documents the complete frontend content-script lifecycle contract", () => { for (const field of APP_BUILDER_FIELDS) { expect(skill, `PluginAppBuilder.${field} is not documented`).toContain( @@ -717,39 +689,6 @@ describe("bb-plugin-authoring skill", () => { } }); - it("keeps environment app symbols and composer and event guidance current", () => { - const frontendIndex = readReference("frontend-api-index.md"); - const backendIndex = readReference("backend-api-index.md"); - const appSymbols = [ - "experimental_BranchPicker", - "BranchPickerProps", - "experimental_useBranches", - "UseBranchesArgs", - "BranchesState", - "experimental_useCheckoutState", - "UseCheckoutStateArgs", - "CheckoutState", - "PluginEnvironmentProviderInputsChange", - "PluginEnvironmentProviderInputsProps", - "PluginEnvironmentProviderInputsRegistration", - ]; - for (const symbol of appSymbols) { - expect(frontendIndex).toContain(`\`${symbol}\``); - expect(backendIndex).not.toContain(`\`${symbol}\``); - } - - expect(readReference("frontend-components.md")).not.toContain( - 'workspace: { type: "personal" }', - ); - expect(readReference("backend-events.md")).toContain("Fourteen events."); - expect(readReference("backend-events.md")).toContain( - "The seven `thread.*` ones", - ); - expect(readReference("testing.md")).toContain( - "Thread events are observe-only; there are exactly seven", - ); - }); - it("documents every navPanel registration field", () => { for (const field of NAV_PANEL_REGISTRATION_FIELDS) { expect( @@ -812,24 +751,6 @@ describe("bb-plugin-authoring skill", () => { } }); - it("documents the explicit plugin branding contract", () => { - expect(skill).toContain("bb.name"); - expect(skill).toContain("bb.description"); - expect(skill).toContain("bb.branding"); - expect(skill).toContain("logo.light"); - expect(skill).toContain("logo.dark"); - expect(skill).toContain("no root logo auto-detection"); - expect(skill).toContain("currentColor"); - expect(skill).toContain("branding.icon"); - expect(skill).toContain("./assets/icon.svg"); - expect(skill).toContain("CSS mask"); - expect(skill).toContain("canonical BB icon name"); - expect(skill).toContain("BB reuses this icon on roomy"); - expect(skill).toContain("Logo-only"); - expect(skill).toContain("manifests remain supported"); - expect(skill).toContain("Do not duplicate"); - }); - it("documents every frontend slot and its prop fields", () => { for (const [slot, fields] of Object.entries(FRONTEND_SLOT_PROP_FIELDS)) { expect(skill, `slot ${slot} is not documented in the skill`).toContain( diff --git a/apps/server/test/skills/submit-a-plugin.test.ts b/apps/server/test/skills/submit-a-plugin.test.ts index 4a67f7273bc..8b7f5b15d4e 100644 --- a/apps/server/test/skills/submit-a-plugin.test.ts +++ b/apps/server/test/skills/submit-a-plugin.test.ts @@ -83,67 +83,4 @@ describe("submit-a-plugin skill", () => { expect(skill).toContain("npm publish --ignore-scripts"); expect(skill).not.toContain("PLUGIN_DISPLAY_NAME"); }); - - it("captures screenshots with a harness tool and falls back to the user", async () => { - const skill = await readSkillTree(); - - expect(skill).toContain( - "Use a browser or computer automation tool that the current harness supplies.", - ); - expect(skill).toContain( - "If the harness supplies no such tool, ask the user for the images.", - ); - expect(skill).toContain("at least 1200 pixels wide"); - expect(skill).toContain("at or below 2 MiB"); - expect(skill).toContain("a maximum of six"); - }); - - it("requires an overview file on every public marketplace entry", async () => { - const skill = await readSkillTree(); - - expect(skill).toContain( - "Every entry in the public marketplace needs an overview file.", - ); - expect(skill).toContain( - "marketplace requires an overview file on every entry.", - ); - expect(skill).toContain( - "draft one from the behavior you observed while validating and screenshotting", - ); - expect(skill).not.toContain("when the entry has no overview file"); - expect(skill).toContain("Copy the file to overview/.md"); - expect(skill).toContain('"overview": "./overview/notes.md"'); - expect(skill).toContain("a maximum of 4000 characters"); - expect(skill).toContain("Each link must be an absolute https URL."); - expect(skill).toContain( - "The build rejects raw HTML, images, tables, footnotes, task lists, and control", - ); - expect(skill).toContain("git add overview/PLUGIN_ID.md"); - }); - - it("states the entry quality rules the store UI depends on", async () => { - const skill = await readSkillTree(); - - expect(skill).toContain("Write an App Store listing, not a README line."); - expect(skill).toContain( - "Do not use these words: powerful, seamless, easy, simple, fast, best, modern,", - ); - expect(skill).toContain("clamps the description to two lines in a"); - expect(skill).toContain( - "Marketplace CI refuses a new or changed entry with no category.", - ); - expect(skill).toContain("The entry has no engines field"); - }); - - it("provides a local submission path without gh", async () => { - const skill = await readSkillTree(); - - expect(skill).toContain("If gh is unavailable or authentication fails"); - expect(skill).toContain( - "git clone https://github.com/get-bb/marketplace.git /SAFE/NEW/PATH/marketplace", - ); - expect(skill).toMatch( - /Return their paths, the clone path,\s+branch name, and results\./, - ); - }); }); From 9b7cf68df9cb33e42fb2ee43f03b4d84eb8f7d8d Mon Sep 17 00:00:00 2001 From: qa Date: Thu, 8 Oct 2026 23:46:37 -0700 Subject: [PATCH 05/10] Add a bench task and record where per-file test time goes `pnpm exec turbo run bench` runs the three opt-in benchmarks that `turbo run test` skips: the sidebar thread list and composer paste benchmarks in @bb/app, and the root watcher recovery benchmark in @bb/host-watcher. Each package's bench script sets its own enable variable. The task is uncached and passes the output and size variables through. Both packages' benches ran through the task. docs/ci-performance.md records the measured per-file costs. A median app jsdom file spends about 0.76 s importing its own graph and 0.5 s loading jsdom in a fresh fork. `vmForks` and Vite's dependency optimizer were both slower in wall time and broke tests, so the config is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/app/package.json | 3 ++- docs/ci-performance.md | 28 ++++++++++++++++++++++++++++ docs/debugging-and-qa.md | 18 ++++++++++++++++++ packages/host-watcher/package.json | 3 ++- turbo.json | 12 ++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index 68d8377c72d..c78da675598 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,7 +14,8 @@ "storybook:build": "ladle build", "lint": "oxlint src vite.config.ts vite.dev.config.ts vite-bundle-stats.ts vite-font-preload.ts vite-split-prefetch.ts vite-forkable-plugin-paths.ts vite-shared-ui-seam.ts vite-react-compiler.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", - "test": "node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts" + "test": "node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts", + "bench": "cross-env BB_SIDEBAR_BENCH=1 PROMPTBOX_PERF=1 vitest run --config vitest.config.ts src/components/sidebar/sidebar.bench.test.tsx src/components/promptbox/editor/prompt-paste-performance.test.ts" }, "dependencies": { "@bb/client-core": "workspace:*", diff --git a/docs/ci-performance.md b/docs/ci-performance.md index b3499df76f9..53480c92fd9 100644 --- a/docs/ci-performance.md +++ b/docs/ci-performance.md @@ -600,3 +600,31 @@ reuse only when a compatible cache already exists; concurrent cold shards still build prerequisites independently. More shards or a shared prerequisite job would trade startup/dependency costs against test time and require a separate controlled comparison before changing the pipeline. + +## Per-file test setup + +Server tests used to run all 143 migrations for each test that needed a database: 607 +migrations and about 58 seconds of CPU per run, at 80 ms each. `@bb/db/testing` now builds +one migrated template per run in `globalSetup` and copies it in 0.4 ms. That leaves 24 +migrations per run: the template build itself, on-disk databases, and migration +replays. A server test rejects new `createConnection(":memory:")` setups. + +In apps/app, about 400 of the roughly 595 files use jsdom. Each one runs isolated in a +fresh fork. Per-file diagnostics on a loaded 16-core Mac show where the median file's time +goes: + +- importing its own module graph: about 0.76 s; +- the jsdom environment: about 0.5 s, mostly `require("jsdom")` (230–535 ms in a fresh + process, against 33 ms to construct a `JSDOM`); +- the shared setup file: about 0.1 s; +- the tests themselves: about 0.35 s. + +Two experiments on an 80-file jsdom sample did not pay off: + +- `vmForks` cut summed environment time from 41 s to 13 s and imports from 61 s to 40 s. + Wall time still rose from 10.8 s to 13.2 s, and six files failed. +- Vite's dependency optimizer did not reduce imports (64 s against 57 s) and broke + `importOriginal` mocks. + +Merging small files would only remove the environment share. Reusing jsdom workers across +files would cut it for every file, but it reverses the rule that DOM tests run isolated. diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md index 0b23101227c..09268b16441 100644 --- a/docs/debugging-and-qa.md +++ b/docs/debugging-and-qa.md @@ -430,6 +430,24 @@ payloads. Use it to reproduce performance problems that only appear at scale. deletes the database file first. Without `--reset` the fixture appends. - Example: `pnpm seed:perf -- --reset --events 400000`. +## Opt-in Benchmarks + +`pnpm exec turbo run bench` runs the benchmarks that `turbo run test` skips. The +task is uncached, and each package's `bench` script turns its benchmark on: + +- `@bb/app`: the sidebar thread list mount and update benchmark + (`sidebar.bench.test.tsx`) and the composer large-paste benchmark + (`prompt-paste-performance.test.ts`). `BB_SIDEBAR_BENCH_THREADS` sets the + thread count (default 3000), `BB_SIDEBAR_BENCH_OUT` writes the results as + JSON, and `PROMPTBOX_PERF_FIXTURE_OUT` saves the paste fixture. +- `@bb/host-watcher`: the root watcher recovery benchmark + (`root-recovery-benchmark.test.ts`). `BB_WATCHER_BENCHMARK_OUTPUT` writes its + report. + +Filter to one package, e.g. +`BB_SIDEBAR_BENCH_OUT=/tmp/sidebar.json pnpm exec turbo run bench --filter=@bb/app`. +Compare runs on an idle machine; the numbers are wall-clock timings. + ## Provider Corpus The provider corpus is a private set of real production threads (307 threads, diff --git a/packages/host-watcher/package.json b/packages/host-watcher/package.json index eb57ae87a64..2659d72e5ba 100644 --- a/packages/host-watcher/package.json +++ b/packages/host-watcher/package.json @@ -13,7 +13,8 @@ "scripts": { "clean": "rimraf dist tsconfig.tsbuildinfo", "typecheck": "tsc --noEmit", - "test": "vitest run --config vitest.config.ts" + "test": "vitest run --config vitest.config.ts", + "bench": "cross-env BB_WATCHER_ROOT_RECOVERY_BENCHMARK=1 vitest run --config vitest.config.ts test/root-recovery-benchmark.test.ts" }, "dependencies": { "@bb/domain": "workspace:*", diff --git a/turbo.json b/turbo.json index 30103d5d941..73d4e43b0dd 100644 --- a/turbo.json +++ b/turbo.json @@ -725,6 +725,18 @@ "$TURBO_ROOT$/vitest*.ts" ] }, + // Opt-in benchmarks: each package's bench script sets its own enable + // variable; output and size knobs pass through. Timings are not cacheable. + "bench": { + "dependsOn": ["//#ensure-native-modules", "topo"], + "cache": false, + "passThroughEnv": [ + "BB_SIDEBAR_BENCH_OUT", + "BB_SIDEBAR_BENCH_THREADS", + "BB_WATCHER_BENCHMARK_OUTPUT", + "PROMPTBOX_PERF_FIXTURE_OUT" + ] + }, // The provider-corpus gates read a private corpus through // BB_PROVIDER_CORPUS_DIR and write snapshots next to it, so the task is // uncacheable and must see those two variables (strict env mode strips From 6fc30e40104740f4d75d0ae82c53ed5ccd2789e9 Mon Sep 17 00:00:00 2001 From: qa Date: Fri, 9 Oct 2026 21:24:31 -0700 Subject: [PATCH 06/10] Add an in-memory Storage to the test helpers Node-environment tests that only need localStorage or sessionStorage can stub it with createMemoryStorage() instead of loading jsdom. Co-Authored-By: Claude Opus 5.5 --- packages/test-helpers/src/index.ts | 1 + packages/test-helpers/src/memory-storage.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 packages/test-helpers/src/memory-storage.ts diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts index 86f13c80f61..c380eb5e6aa 100644 --- a/packages/test-helpers/src/index.ts +++ b/packages/test-helpers/src/index.ts @@ -25,3 +25,4 @@ export { } from "./provider-corpus.js"; export type { CorpusThread } from "./provider-corpus.js"; export { makeTempDir, makeTempDirSync } from "./temp-dir.js"; +export { createMemoryStorage } from "./memory-storage.js"; diff --git a/packages/test-helpers/src/memory-storage.ts b/packages/test-helpers/src/memory-storage.ts new file mode 100644 index 00000000000..e115a543eaa --- /dev/null +++ b/packages/test-helpers/src/memory-storage.ts @@ -0,0 +1,21 @@ +export function createMemoryStorage( + initialEntries: Record = {}, +): Storage { + const entries = new Map(Object.entries(initialEntries)); + return { + get length() { + return entries.size; + }, + clear: () => { + entries.clear(); + }, + getItem: (key) => entries.get(key) ?? null, + key: (index) => [...entries.keys()][index] ?? null, + removeItem: (key) => { + entries.delete(key); + }, + setItem: (key, value) => { + entries.set(key, String(value)); + }, + }; +} From 23cda9b70d06784b2605e7d900fd06f57646d2a4 Mon Sep 17 00:00:00 2001 From: qa Date: Fri, 9 Oct 2026 22:27:15 -0700 Subject: [PATCH 07/10] Move DOM-free tests out of jsdom and delete duplicated ones An audit of every jsdom test file (mutations of the code under test, then which test files fail) found most of them guard behavior only a DOM shows, but about 100 run in jsdom for logic that needs none, paying ~1.7 s of environment setup each. This moves that logic to node tests and deletes the files whose catches are duplicated elsewhere. jsdom test files go from 496 to 403. - Pragma only: files that pass unchanged in node drop the jsdom line. - Storage only: node tests stub `localStorage`/`sessionStorage` with `createMemoryStorage()` (plugins keep a local copy). - TanStack Query hooks with no DOM assertions: option factories are exported and driven with `QueryObserver`/`MutationObserver` on a `QueryClient`. - Pure logic inside components and hooks is extracted into functions the component still calls, and table-tested in node. Hooks that only need React render through `renderHookStatically` (`renderToStaticMarkup`). - Partial files are split; the DOM-needing cases stay in jsdom. - Deleted where every catch is also caught elsewhere, the test covers story-only code, or it passes for an unrelated reason; a single unique catch moves into the stronger owner's test (e.g. ProviderIcon, openThreadInSplit). - `sidebar.bench.test.tsx` leaves the default include (`bench` still runs it); the templates scaffold test declares the node environment, since a template literal containing the jsdom pragma was selecting jsdom. - theme-preview declares the SDK test host's optional peers (better-sqlite3, cron-parser, hono) now that its server test uses the fake host. Every unique catch the audit recorded for a moved file was re-applied as a mutation: the moved test fails with it and passes without it. Known losses: thin hook wiring that now only forwards to extracted functions (held read status effect, sidebar reveal atom setters, a tasks scheduler null-check). Co-Authored-By: Claude Opus 5.5 --- .../components/AppToaster.loading.test.tsx | 30 - .../code/renderer-lazy-loading.test.tsx | 96 --- .../git-diff/git-diff-parsing.test.ts | 1 - .../components/layout/AppPageHeader.test.tsx | 99 --- .../src/components/layout/AppPageHeader.tsx | 17 +- .../layout/windowTopLeftReserve.test.ts | 64 ++ .../components/layout/windowTopLeftReserve.ts | 28 + .../components/machines/MachineLabel.test.ts | 40 ++ .../components/machines/MachineLabel.test.tsx | 49 -- .../src/components/machines/MachineLabel.tsx | 30 +- .../plugin/EnvironmentProviderIcon.test.tsx | 63 -- ...test.tsx => PluginInstallJobsHost.test.ts} | 77 +- .../plugin/PluginInstallJobsHost.tsx | 120 ++-- .../plugin/PluginUpdateJobsHost.test.ts | 62 ++ .../plugin/PluginUpdateJobsHost.test.tsx | 74 -- .../plugin/PluginUpdateJobsHost.tsx | 110 +-- .../components/plugin/ProviderIcon.test.tsx | 37 + ...luginBranchPickerState.source-host.test.ts | 47 ++ .../usePluginBranchPickerState.test.tsx | 29 +- .../plugin/usePluginBranchPickerState.ts | 25 +- .../banner/ProviderCliBanner.test.tsx | 119 ++- .../banner/QueuedMessagesList.drag.test.tsx | 114 --- .../banner/ThreadMachineStatus.test.tsx | 77 +- .../banner/queued-messages-expanded.test.ts | 66 ++ .../banner/queued-messages-expanded.test.tsx | 62 -- .../banner/queued-messages-expanded.ts | 117 +-- .../mentions/initial-prompt-draft.test.ts | 177 +++++ .../mentions/initial-prompt-draft.test.tsx | 295 -------- .../mentions/initial-prompt-draft.ts | 43 +- .../mentions/prompt-mention-clipboard.test.ts | 23 +- .../mentions/prompt-mention-clipboard.ts | 2 +- .../promptbox/prompt-voice-intent.test.ts | 160 +++++ .../promptbox/prompt-voice-intent.ts | 87 +++ .../usePendingAttachmentUploads.test.ts | 17 + .../promptbox/usePendingAttachmentUploads.ts | 8 +- .../promptbox/usePromptVoice.test.tsx | 246 ------- .../components/promptbox/usePromptVoice.ts | 111 +-- ....test.tsx => provider-cli-install.test.ts} | 299 ++++---- .../TerminalHostSelector.test.ts | 55 ++ .../TerminalHostSelector.test.tsx | 72 -- .../ThreadMetadataContent.dom.test.tsx | 83 +++ .../ThreadMetadataContent.test.tsx | 71 +- ...ryPanelTabContent.markdown-images.test.tsx | 61 +- .../git-diff/gitDiffPanelHelpers.test.ts | 41 ++ .../git-diff/gitDiffPanelHelpers.ts | 31 + .../git-diff/useGitDiffPanel.test.tsx | 233 +----- .../git-diff/useGitDiffPanel.ts | 52 +- .../useResponsiveGitDiffPanelDisplay.test.ts | 85 +++ .../useResponsiveGitDiffPanelDisplay.test.tsx | 254 ------- .../useResponsiveGitDiffPanelDisplay.ts | 36 +- .../secondary-panel/usePanelBrowser.test.tsx | 181 ++--- .../secondary-panel/usePanelBrowser.ts | 47 +- .../secondary-panel/usePanelFiles.test.tsx | 40 +- .../usePanelPluginPanels.test.tsx | 64 +- .../useThreadStorageBrowser.test.ts | 74 ++ .../useThreadStorageBrowser.test.tsx | 105 --- .../useThreadStorageBrowser.ts | 151 ++-- .../settings/SplitDimmingSetting.test.ts | 7 + .../settings/SplitDimmingSetting.test.tsx | 43 -- .../settings/SplitDimmingSetting.tsx | 2 +- .../components/settings/settings-nav.test.ts | 52 ++ .../components/settings/settings-nav.test.tsx | 114 --- .../src/components/settings/settings-nav.tsx | 95 ++- .../sidebar/paneContentSplitIndicator.test.ts | 67 ++ .../paneContentSplitIndicator.test.tsx | 88 --- .../sidebar/paneContentSplitIndicator.ts | 52 +- .../sidebar/useThreadRowSplitDrag.test.tsx | 140 ---- .../ThreadTitleMentions.resources.test.ts | 124 ++++ .../components/thread/ThreadTitleMentions.tsx | 2 +- .../useComposerAttachmentUploads.test.ts | 203 ++++++ .../useComposerAttachmentUploads.test.tsx | 275 ------- .../useComposerAttachmentUploads.ts | 185 +++-- ...t.tsx => useQueuedMessageEditHold.test.ts} | 40 +- .../embedded-chat/useQueuedMessageEditHold.ts | 108 +-- .../terminal/TerminalLinkOpenDialog.test.tsx | 52 -- .../ThreadTerminalView.context-menu.test.tsx | 75 -- ...useThreadTerminalController.mount.test.tsx | 128 ---- .../useThreadTerminalController.test.ts | 88 +++ .../terminal/useThreadTerminalController.ts | 89 ++- ...versationMessageContent.streaming.test.tsx | 113 --- .../timeline/ConversationMessageContent.tsx | 13 +- .../LazyTimelineFileDiffBlock.test.tsx | 57 -- .../thread/timeline/MessageActionBar.test.ts | 70 ++ .../thread/timeline/MessageActionBar.test.tsx | 69 -- .../timeline/TerminalOutputBlock.test.tsx | 48 -- .../ThreadContextWindowIndicator.test.tsx | 68 -- .../ThreadTimelinePanelContent.test.tsx | 195 ----- .../timeline/ThreadTimelinePanelContent.tsx | 36 +- ...readTimelineRows.render-stability.test.tsx | 136 ---- .../thread/timeline/ThreadTimelineRows.tsx | 2 +- ...ThreadTimelineRows.unread-divider.test.ts} | 61 +- ...TimelineRowDetails.output-preview.test.tsx | 131 ---- .../timeline/streaming-markdown-split.test.ts | 92 ++- .../timeline/streaming-markdown-split.ts | 14 + .../thread-context-window-usage.test.ts | 31 + .../timeline/thread-ongoing-indicator.test.ts | 39 + .../timeline/thread-ongoing-indicator.ts | 43 ++ .../useTimelineWorkRowFullOutput.test.ts | 128 ++++ .../timeline/useTimelineWorkRowFullOutput.ts | 88 +-- .../tools/ResourceSidebars.test.tsx | 23 - .../components/tools/tools-navigation.test.ts | 18 + .../components/ui/app-toast-runtime.test.ts | 20 + ...anchored-scroll-body.anchor-class.test.tsx | 87 +-- .../ui/route-loading-skeleton.test.tsx | 52 +- .../ui/route-navigation-indicator.test.ts | 76 ++ .../ui/route-navigation-indicator.test.tsx | 80 --- .../ui/route-navigation-indicator.tsx | 76 +- .../ui/scroll-to-bottom-button.test.tsx | 51 +- .../sidebar-thread-placeholder.test.ts | 2 - ...ns.test.tsx => settings-mutations.test.ts} | 215 +++--- .../src/hooks/mutations/settings-mutations.ts | 19 +- .../thread-interaction-mutations.test.ts | 128 ++++ .../thread-interaction-mutations.test.tsx | 158 ---- .../mutations/thread-interaction-mutations.ts | 60 +- ...t.tsx => thread-runtime-mutations.test.ts} | 283 ++++---- .../mutations/thread-runtime-mutations.ts | 97 ++- ...est.tsx => thread-state-mutations.test.ts} | 51 +- .../hooks/mutations/thread-state-mutations.ts | 39 +- .../src/hooks/plugin-startup-recovery.test.ts | 2 - .../hooks/pluginMentionSuggestions.test.ts | 2 +- .../app/src/hooks/pluginMentionSuggestions.ts | 2 +- ...st.tsx => host-file-preview-query.test.ts} | 92 ++- .../hooks/queries/host-file-preview-query.ts | 14 +- .../hooks/queries/palette-thread-queries.ts | 20 +- .../queries/plugin-contribution-queries.ts | 130 +--- ...x => plugin-contribution-requests.test.ts} | 124 ++-- .../queries/plugin-contribution-requests.ts | 128 ++++ .../hooks/queries/sidebar-navigation-query.ts | 38 +- ...ueries.test.tsx => system-queries.test.ts} | 548 +++++++------- apps/app/src/hooks/queries/system-queries.ts | 77 +- ...ueries.test.tsx => thread-queries.test.ts} | 680 ++++++++++-------- apps/app/src/hooks/queries/thread-queries.ts | 356 +++++---- ...-read-sidebar-refresh.integration.test.ts} | 91 ++- .../hooks/useAppSettingsRouteMemory.test.ts | 100 +++ .../hooks/useAppSettingsRouteMemory.test.tsx | 183 ----- .../src/hooks/useAppSettingsRouteMemory.ts | 119 ++- .../useCommandSuggestions.prefetch.test.ts | 38 + .../useCommandSuggestions.prefetch.test.tsx | 151 ---- .../src/hooks/useCommandSuggestions.test.ts | 78 ++ apps/app/src/hooks/useCommandSuggestions.ts | 132 +++- .../useCreateThreadInEnvironment.test.ts | 42 +- .../src/hooks/useCreateThreadInEnvironment.ts | 22 +- .../useLocalPathPicker.path-entry.test.ts | 54 ++ .../app/src/hooks/useLocalPathPicker.test.tsx | 66 -- apps/app/src/hooks/useLocalPathPicker.tsx | 25 +- .../usePluginSafeModeCommands.dom.test.tsx | 93 +++ .../hooks/usePluginSafeModeCommands.test.ts | 72 ++ .../hooks/usePluginSafeModeCommands.test.tsx | 170 ----- .../src/hooks/usePluginSafeModeCommands.ts | 85 ++- .../hooks/useResolvedLiveFileTarget.test.ts | 84 +++ .../hooks/useResolvedLiveFileTarget.test.tsx | 112 --- .../src/hooks/useResolvedLiveFileTarget.ts | 184 +++-- apps/app/src/lib/app-keybindings.dom.test.ts | 17 + apps/app/src/lib/app-keybindings.test.ts | 16 - .../src/lib/app-route-history.stack.test.ts | 73 ++ apps/app/src/lib/app-route-history.test.tsx | 122 +--- apps/app/src/lib/app-route-history.ts | 16 +- apps/app/src/lib/bb-desktop.test.ts | 25 + apps/app/src/lib/bb-desktop.ts | 23 +- .../app/src/lib/bb-desktop.windowFind.test.ts | 34 - apps/app/src/lib/browser-storage-enum.test.ts | 73 ++ .../src/lib/fixed-panel-tabs-state.test.ts | 55 +- .../app/src/lib/fixed-panel-tabs-sync.test.ts | 244 ------- .../fixed-panel-tabs.plugin-terminal.test.ts | 27 + apps/app/src/lib/fixed-panel-tabs.ts | 40 +- apps/app/src/lib/last-known-cache.test.ts | 46 +- ...sx => pierre-strict-mode-recovery.test.ts} | 38 +- .../src/lib/pierre-strict-mode-recovery.ts | 45 +- .../src/lib/pierre-worker-pool-theme.test.ts | 48 ++ .../src/lib/pierre-worker-pool-theme.test.tsx | 90 --- apps/app/src/lib/pierre-worker-pool-theme.ts | 39 +- .../lib/plugin-frontend-load-order.test.ts | 2 - .../src/lib/plugin-nav-panel-chrome.test.ts | 199 +++++ .../src/lib/plugin-nav-panel-chrome.test.tsx | 219 ------ apps/app/src/lib/plugin-nav-panel-chrome.ts | 63 +- apps/app/src/lib/split-layout/atoms.test.ts | 41 +- .../split-layout/openThreadInSplit.test.ts | 67 +- .../app/src/lib/split-resize-snap.dom.test.ts | 138 ++++ apps/app/src/lib/split-resize-snap.test.ts | 245 ++----- apps/app/src/lib/split-resize-snap.ts | 107 +-- .../src/lib/thread-lifecycle-filter.test.ts | 22 +- apps/app/src/lib/thread-tabs-sync.test.ts | 128 +++- apps/app/src/lib/thread-tabs-sync.ts | 26 + .../ui-preferences/UiPreferencesSync.test.tsx | 79 -- .../lib/ui-preferences/UiPreferencesSync.tsx | 5 - .../ui-preferences-sync.test.ts | 6 +- ...e-open-target-preference.override.test.tsx | 84 --- .../workspace-open-target-preference.test.ts | 18 + .../lib/workspace-open-target-preference.ts | 17 +- apps/app/src/test/render-hook-statically.tsx | 15 + .../src/views/RootComposeCompactHome.test.ts | 29 + .../src/views/RootComposeCompactHome.test.tsx | 31 +- .../views/RootComposeMobileRecents.test.ts | 181 +++++ .../views/RootComposeMobileRecents.test.tsx | 155 +--- .../views/RootComposePanelTabContent.test.tsx | 244 ------- apps/app/src/views/RootComposeView.test.ts | 16 + .../views/SettingsView.experiments.test.ts | 17 + .../views/SettingsView.experiments.test.tsx | 46 -- .../src/views/SettingsView.stories.test.tsx | 118 --- apps/app/src/views/SettingsView.tsx | 55 +- .../LazyThreadDetailView.test.tsx | 56 -- .../SplitThreadArea.parity.test.tsx | 94 --- .../useMobileRecentsThreadReveal.test.ts | 117 +++ .../useMobileRecentsThreadReveal.test.tsx | 172 ----- .../src/views/useMobileRecentsThreadReveal.ts | 141 ++-- apps/app/vitest.config.ts | 7 + .../test/plugin-scaffold-external.test.ts | 1 + plugins/concurrency-limit/app.test.tsx | 37 - plugins/concurrency-limit/app.tsx | 55 +- plugins/concurrency-limit/limits.test.ts | 90 +++ plugins/concurrency-limit/limits.ts | 60 ++ plugins/drafts/app.test.tsx | 14 +- .../github/components/markdown-lite.test.tsx | 54 -- plugins/github/components/markdown-lite.tsx | 94 +-- .../github/components/markdown-table.test.ts | 42 ++ plugins/github/components/markdown-table.ts | 95 +++ plugins/plugin-api-docs/app.test.tsx | 12 +- plugins/provider-usage/server.ts | 4 +- plugins/provider-usage/settings.test.tsx | 369 +++++----- plugins/provider-usage/settings.tsx | 81 ++- plugins/side-chat/app.test.tsx | 204 +----- plugins/side-chat/message-action.test.ts | 155 ++++ plugins/side-chat/tsconfig.json | 1 + plugins/tasks/api/api.test.ts | 39 + plugins/tasks/shell/data.ts | 227 +++--- plugins/tasks/shell/incremental-api.test.tsx | 91 --- plugins/tasks/shell/incremental.test.ts | 222 ++++++ plugins/tasks/shell/incremental.test.tsx | 230 ------ plugins/tasks/shell/query-signals.test.tsx | 163 ----- plugins/tasks/shell/signal-scheduler.test.ts | 166 +++++ plugins/tasks/shell/signal-scheduler.ts | 128 ++++ plugins/tasks/shell/view-preference.test.ts | 25 +- plugins/tasks/test-fixtures.ts | 22 + plugins/tasks/views/activity/activity.test.ts | 48 +- .../views/activity/comment-author.test.tsx | 71 -- plugins/tasks/views/board/board-data.ts | 190 +++++ plugins/tasks/views/board/index.tsx | 200 +----- plugins/tasks/views/list/data.ts | 26 +- plugins/tasks/views/list/lib.test.ts | 55 +- plugins/tasks/views/list/lib.ts | 7 +- .../tasks/views/list/list-preference.test.ts | 22 +- .../tasks/views/list/property-menus.test.ts | 1 - .../tasks/views/list/row-metadata.test.tsx | 216 ------ plugins/tasks/views/list/row.tsx | 7 +- plugins/theme-preview/package.json | 4 + .../app/dnd/sidebarThreadDragChip.test.ts | 72 +- .../app/dnd/sidebarThreadDragChip.tsx | 44 +- .../app/list/useReadStatusGrouping.test.tsx | 60 -- .../app/list/useReadStatusGrouping.ts | 25 +- .../list/useSidebarModeSectionOrder.test.tsx | 137 ---- .../app/list/useSidebarModeSectionOrder.ts | 40 +- .../app/list/useSidebarThreadReveal.test.ts | 198 +++++ .../app/list/useSidebarThreadReveal.test.tsx | 248 ------- .../app/list/useSidebarThreadReveal.ts | 218 ++++-- .../app/model/read-status-grouping.test.ts | 49 +- .../app/model/read-status-grouping.ts | 29 + .../app/model/sidebar-section-order.test.ts | 41 ++ .../app/model/sidebar-section-order.ts | 39 + .../app/preferences/preferences-sync.test.ts | 47 +- .../app/preferences/preferences-sync.ts | 14 +- .../app/rows/SplitPaneMiniMap.test.ts | 69 ++ .../app/rows/SplitPaneMiniMap.test.tsx | 93 --- .../thread-list/app/rows/SplitPaneMiniMap.tsx | 33 +- pnpm-lock.yaml | 12 + 264 files changed, 10706 insertions(+), 12290 deletions(-) delete mode 100644 apps/app/src/components/AppToaster.loading.test.tsx delete mode 100644 apps/app/src/components/code/renderer-lazy-loading.test.tsx delete mode 100644 apps/app/src/components/layout/AppPageHeader.test.tsx create mode 100644 apps/app/src/components/layout/windowTopLeftReserve.test.ts create mode 100644 apps/app/src/components/layout/windowTopLeftReserve.ts create mode 100644 apps/app/src/components/machines/MachineLabel.test.ts delete mode 100644 apps/app/src/components/machines/MachineLabel.test.tsx delete mode 100644 apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx rename apps/app/src/components/plugin/{PluginInstallJobsHost.test.tsx => PluginInstallJobsHost.test.ts} (60%) create mode 100644 apps/app/src/components/plugin/PluginUpdateJobsHost.test.ts delete mode 100644 apps/app/src/components/plugin/PluginUpdateJobsHost.test.tsx create mode 100644 apps/app/src/components/plugin/usePluginBranchPickerState.source-host.test.ts delete mode 100644 apps/app/src/components/promptbox/banner/QueuedMessagesList.drag.test.tsx create mode 100644 apps/app/src/components/promptbox/banner/queued-messages-expanded.test.ts create mode 100644 apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.ts delete mode 100644 apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.tsx create mode 100644 apps/app/src/components/promptbox/prompt-voice-intent.test.ts create mode 100644 apps/app/src/components/promptbox/prompt-voice-intent.ts create mode 100644 apps/app/src/components/promptbox/usePendingAttachmentUploads.test.ts delete mode 100644 apps/app/src/components/promptbox/usePromptVoice.test.tsx rename apps/app/src/components/provider-cli/{provider-cli-install.test.tsx => provider-cli-install.test.ts} (67%) create mode 100644 apps/app/src/components/secondary-panel/TerminalHostSelector.test.ts delete mode 100644 apps/app/src/components/secondary-panel/TerminalHostSelector.test.tsx create mode 100644 apps/app/src/components/secondary-panel/ThreadMetadataContent.dom.test.tsx create mode 100644 apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.ts delete mode 100644 apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx create mode 100644 apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.ts delete mode 100644 apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx create mode 100644 apps/app/src/components/settings/SplitDimmingSetting.test.ts delete mode 100644 apps/app/src/components/settings/SplitDimmingSetting.test.tsx create mode 100644 apps/app/src/components/settings/settings-nav.test.ts delete mode 100644 apps/app/src/components/settings/settings-nav.test.tsx create mode 100644 apps/app/src/components/sidebar/paneContentSplitIndicator.test.ts delete mode 100644 apps/app/src/components/sidebar/paneContentSplitIndicator.test.tsx delete mode 100644 apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx create mode 100644 apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.ts delete mode 100644 apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx rename apps/app/src/components/thread/embedded-chat/{useQueuedMessageEditHold.test.tsx => useQueuedMessageEditHold.test.ts} (74%) delete mode 100644 apps/app/src/components/thread/terminal/TerminalLinkOpenDialog.test.tsx delete mode 100644 apps/app/src/components/thread/terminal/ThreadTerminalView.context-menu.test.tsx delete mode 100644 apps/app/src/components/thread/terminal/useThreadTerminalController.mount.test.tsx create mode 100644 apps/app/src/components/thread/terminal/useThreadTerminalController.test.ts delete mode 100644 apps/app/src/components/thread/timeline/LazyTimelineFileDiffBlock.test.tsx create mode 100644 apps/app/src/components/thread/timeline/MessageActionBar.test.ts delete mode 100644 apps/app/src/components/thread/timeline/TerminalOutputBlock.test.tsx delete mode 100644 apps/app/src/components/thread/timeline/ThreadContextWindowIndicator.test.tsx delete mode 100644 apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx delete mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineRows.render-stability.test.tsx rename apps/app/src/components/thread/timeline/{ThreadTimelineRows.unread-divider.test.tsx => ThreadTimelineRows.unread-divider.test.ts} (65%) create mode 100644 apps/app/src/components/thread/timeline/thread-context-window-usage.test.ts create mode 100644 apps/app/src/components/thread/timeline/thread-ongoing-indicator.test.ts create mode 100644 apps/app/src/components/thread/timeline/thread-ongoing-indicator.ts create mode 100644 apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.test.ts create mode 100644 apps/app/src/components/tools/tools-navigation.test.ts create mode 100644 apps/app/src/components/ui/app-toast-runtime.test.ts create mode 100644 apps/app/src/components/ui/route-navigation-indicator.test.ts delete mode 100644 apps/app/src/components/ui/route-navigation-indicator.test.tsx rename apps/app/src/hooks/mutations/{settings-mutations.test.tsx => settings-mutations.test.ts} (66%) create mode 100644 apps/app/src/hooks/mutations/thread-interaction-mutations.test.ts delete mode 100644 apps/app/src/hooks/mutations/thread-interaction-mutations.test.tsx rename apps/app/src/hooks/mutations/{thread-runtime-mutations.test.tsx => thread-runtime-mutations.test.ts} (67%) rename apps/app/src/hooks/mutations/{thread-state-mutations.test.tsx => thread-state-mutations.test.ts} (78%) rename apps/app/src/hooks/queries/{host-file-preview-query.test.tsx => host-file-preview-query.test.ts} (60%) rename apps/app/src/hooks/queries/{plugin-contribution-queries.test.tsx => plugin-contribution-requests.test.ts} (53%) create mode 100644 apps/app/src/hooks/queries/plugin-contribution-requests.ts rename apps/app/src/hooks/queries/{system-queries.test.tsx => system-queries.test.ts} (63%) rename apps/app/src/hooks/queries/{thread-queries.test.tsx => thread-queries.test.ts} (61%) rename apps/app/src/hooks/{thread-read-sidebar-refresh.integration.test.tsx => thread-read-sidebar-refresh.integration.test.ts} (60%) create mode 100644 apps/app/src/hooks/useAppSettingsRouteMemory.test.ts delete mode 100644 apps/app/src/hooks/useAppSettingsRouteMemory.test.tsx create mode 100644 apps/app/src/hooks/useCommandSuggestions.prefetch.test.ts delete mode 100644 apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx create mode 100644 apps/app/src/hooks/useLocalPathPicker.path-entry.test.ts create mode 100644 apps/app/src/hooks/usePluginSafeModeCommands.dom.test.tsx create mode 100644 apps/app/src/hooks/usePluginSafeModeCommands.test.ts delete mode 100644 apps/app/src/hooks/usePluginSafeModeCommands.test.tsx create mode 100644 apps/app/src/hooks/useResolvedLiveFileTarget.test.ts delete mode 100644 apps/app/src/hooks/useResolvedLiveFileTarget.test.tsx create mode 100644 apps/app/src/lib/app-keybindings.dom.test.ts create mode 100644 apps/app/src/lib/app-route-history.stack.test.ts delete mode 100644 apps/app/src/lib/bb-desktop.windowFind.test.ts create mode 100644 apps/app/src/lib/browser-storage-enum.test.ts rename apps/app/src/lib/{pierre-strict-mode-recovery.test.tsx => pierre-strict-mode-recovery.test.ts} (62%) create mode 100644 apps/app/src/lib/pierre-worker-pool-theme.test.ts delete mode 100644 apps/app/src/lib/pierre-worker-pool-theme.test.tsx create mode 100644 apps/app/src/lib/plugin-nav-panel-chrome.test.ts delete mode 100644 apps/app/src/lib/plugin-nav-panel-chrome.test.tsx create mode 100644 apps/app/src/lib/split-resize-snap.dom.test.ts delete mode 100644 apps/app/src/lib/ui-preferences/UiPreferencesSync.test.tsx delete mode 100644 apps/app/src/lib/workspace-open-target-preference.override.test.tsx create mode 100644 apps/app/src/test/render-hook-statically.tsx create mode 100644 apps/app/src/views/RootComposeCompactHome.test.ts create mode 100644 apps/app/src/views/RootComposeMobileRecents.test.ts delete mode 100644 apps/app/src/views/RootComposePanelTabContent.test.tsx create mode 100644 apps/app/src/views/SettingsView.experiments.test.ts delete mode 100644 apps/app/src/views/SettingsView.experiments.test.tsx delete mode 100644 apps/app/src/views/SettingsView.stories.test.tsx delete mode 100644 apps/app/src/views/thread-detail/LazyThreadDetailView.test.tsx delete mode 100644 apps/app/src/views/thread-detail/SplitThreadArea.parity.test.tsx create mode 100644 apps/app/src/views/useMobileRecentsThreadReveal.test.ts delete mode 100644 apps/app/src/views/useMobileRecentsThreadReveal.test.tsx create mode 100644 plugins/concurrency-limit/limits.test.ts create mode 100644 plugins/github/components/markdown-table.test.ts create mode 100644 plugins/github/components/markdown-table.ts create mode 100644 plugins/side-chat/message-action.test.ts delete mode 100644 plugins/tasks/shell/incremental-api.test.tsx create mode 100644 plugins/tasks/shell/incremental.test.ts delete mode 100644 plugins/tasks/shell/incremental.test.tsx delete mode 100644 plugins/tasks/shell/query-signals.test.tsx create mode 100644 plugins/tasks/shell/signal-scheduler.test.ts create mode 100644 plugins/tasks/shell/signal-scheduler.ts delete mode 100644 plugins/tasks/views/activity/comment-author.test.tsx create mode 100644 plugins/tasks/views/board/board-data.ts delete mode 100644 plugins/tasks/views/list/row-metadata.test.tsx delete mode 100644 plugins/thread-list/app/list/useReadStatusGrouping.test.tsx delete mode 100644 plugins/thread-list/app/list/useSidebarModeSectionOrder.test.tsx create mode 100644 plugins/thread-list/app/list/useSidebarThreadReveal.test.ts delete mode 100644 plugins/thread-list/app/list/useSidebarThreadReveal.test.tsx create mode 100644 plugins/thread-list/app/rows/SplitPaneMiniMap.test.ts delete mode 100644 plugins/thread-list/app/rows/SplitPaneMiniMap.test.tsx diff --git a/apps/app/src/components/AppToaster.loading.test.tsx b/apps/app/src/components/AppToaster.loading.test.tsx deleted file mode 100644 index 829c09a4e6b..00000000000 --- a/apps/app/src/components/AppToaster.loading.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, render, waitFor } from "@testing-library/react"; -import { afterEach, expect, it } from "vitest"; -import { toast } from "sonner"; -import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; -import { AppToaster } from "./AppToaster"; -import { appToast } from "./ui/app-toast"; - -afterEach(() => { - toast.dismiss(); - cleanup(); -}); - -it("shows a toast fired before the toaster has loaded once it mounts", async () => { - render( - - - , - ); - expect(document.querySelector("[data-sonner-toaster]")).toBeNull(); - act(() => { - appToast.success("Saved before the toaster loaded"); - }); - await waitFor(() => - expect( - document.body.textContent?.includes("Saved before the toaster loaded"), - ).toBe(true), - ); -}); diff --git a/apps/app/src/components/code/renderer-lazy-loading.test.tsx b/apps/app/src/components/code/renderer-lazy-loading.test.tsx deleted file mode 100644 index b63b160b8bf..00000000000 --- a/apps/app/src/components/code/renderer-lazy-loading.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @vitest-environment jsdom - -import { - act, - cleanup, - fireEvent, - render, - screen, -} from "@testing-library/react"; -import { Provider } from "jotai"; -import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - PluginDiffRendererProps, - PluginSourceCodeRendererProps, -} from "@get-bb/plugin-sdk"; -import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing"; -import { - resetPluginSlotStoreForTest, - setPluginSlotRegistrations, -} from "@/lib/plugin-slots"; -import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; -import { DiffHost } from "./DiffHost"; -import { SourceCodeHost } from "./SourceCodeHost"; - -const loaded = vi.hoisted(() => ({ diff: false, source: false })); - -vi.mock("./BbDiff", () => { - loaded.diff = true; - return { default: () =>
}; -}); - -vi.mock("./BbSourceCode", () => { - loaded.source = true; - return { default: () =>
}; -}); - -function Replacement({ - Original, -}: Pick) { - const [delegates, setDelegates] = useState(false); - return ( -
- - {delegates ? : Replacement renderer} -
- ); -} - -afterEach(() => { - cleanup(); - resetPluginSlotStoreForTest(); -}); - -describe("replacement renderer lazy loading", () => { - it.each(["diff", "source"] as const)( - "imports the built-in %s renderer only after the replacement delegates", - async (kind) => { - setPluginSlotRegistrations( - "demo", - makePluginRegistrationSet({ - diffRenderers: [ - { id: "diff", title: "Diff", component: Replacement }, - ], - sourceCodeRenderers: [ - { id: "source", title: "Source", component: Replacement }, - ], - }), - ); - const file = parseGitDiffFiles( - "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n", - )[0]; - if (file === undefined) throw new Error("Fixture did not parse"); - render( - - {kind === "diff" ? ( - - ) : ( - - )} - , - ); - await screen.findByText("Replacement renderer"); - await act(async () => {}); - expect(loaded[kind]).toBe(false); - - fireEvent.click( - screen.getByRole("button", { name: "Use original renderer" }), - ); - await screen.findByTestId(`built-in-${kind}`); - expect(loaded[kind]).toBe(true); - }, - ); -}); diff --git a/apps/app/src/components/git-diff/git-diff-parsing.test.ts b/apps/app/src/components/git-diff/git-diff-parsing.test.ts index 1f14414e5df..bde863136f1 100644 --- a/apps/app/src/components/git-diff/git-diff-parsing.test.ts +++ b/apps/app/src/components/git-diff/git-diff-parsing.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { enrichGitDiffFileForContext, diff --git a/apps/app/src/components/layout/AppPageHeader.test.tsx b/apps/app/src/components/layout/AppPageHeader.test.tsx deleted file mode 100644 index 230a7278c18..00000000000 --- a/apps/app/src/components/layout/AppPageHeader.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import type { BbDesktopInfo } from "@bb/desktop-contract"; -import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; -import { SidebarProvider } from "@/components/ui/sidebar"; -import { - BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, - MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, -} from "@/lib/bb-desktop"; -import { createBbDesktopApi } from "@/test/bb-desktop-test-utils"; -import { AppPageHeader } from "./AppPageHeader"; - -const macosDesktopInfo: BbDesktopInfo = { - lastCheckedAt: null, - latestVersion: null, - pendingVersion: null, - platform: "macos", - updateAvailable: false, - updateDownloaded: false, - version: "0.0.0-test", -}; - -const RESERVE_CLASSES = [ - BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, - MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, -]; - -function renderHeaderReserve({ - collapsedRailWidth, - isCompactViewport = false, - open = false, -}: { - collapsedRailWidth?: string; - isCompactViewport?: boolean; - open?: boolean; -}): string[] { - render( - - - - - , - ); - const classNames = screen - .getByTestId("app-page-header-content-row") - .className.split(" "); - return RESERVE_CLASSES.filter((reserve) => - reserve.split(" ").every((token) => classNames.includes(token)), - ); -} - -afterEach(() => { - cleanup(); - delete window.bbDesktop; -}); - -describe("AppPageHeader window top-left reserve", () => { - it("reserves the browser trigger slot when the sidebar slides fully away", () => { - expect(renderHeaderReserve({})).toEqual([ - BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, - ]); - }); - - it("reserves nothing beside a collapsed rail, which keeps the trigger", () => { - expect(renderHeaderReserve({ collapsedRailWidth: "52px" })).toEqual([]); - }); - - it("reserves nothing beside a collapsed rail on macOS, where the title bar hosts the lights", () => { - window.bbDesktop = createBbDesktopApi(macosDesktopInfo); - - expect(renderHeaderReserve({ collapsedRailWidth: "52px" })).toEqual([]); - }); - - it("keeps the full macOS reserve when no rail stays behind", () => { - window.bbDesktop = createBbDesktopApi(macosDesktopInfo); - - expect(renderHeaderReserve({})).toEqual([ - MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, - ]); - }); - - it("still reserves the trigger slot on compact viewports with the rail experiment on", () => { - expect( - renderHeaderReserve({ - collapsedRailWidth: "52px", - isCompactViewport: true, - open: true, - }), - ).toEqual([BROWSER_COLLAPSED_HEADER_RESERVE_CLASS]); - }); - - it("reserves nothing while the sidebar is showing", () => { - expect( - renderHeaderReserve({ collapsedRailWidth: "52px", open: true }), - ).toEqual([]); - }); -}); diff --git a/apps/app/src/components/layout/AppPageHeader.tsx b/apps/app/src/components/layout/AppPageHeader.tsx index 11f6ebd3e61..9357faf7de1 100644 --- a/apps/app/src/components/layout/AppPageHeader.tsx +++ b/apps/app/src/components/layout/AppPageHeader.tsx @@ -10,12 +10,10 @@ import { } from "@bb/shared-ui/coarse-pointer-sizing"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { - BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, CHROME_ROW_CLASS, CHROME_ROW_HEIGHT_CLASS, getBbDesktopInfo, MACOS_CHROME_CONTROL_AXIS_CLASS, - MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, MACOS_WINDOW_DRAG_CLASS, MACOS_WINDOW_NO_DRAG_CLASS, shouldReserveMacosTrafficLights, @@ -24,6 +22,7 @@ import { import { useDesktopWindowState } from "@/hooks/useDesktopWindowState"; import { cn } from "@bb/shared-ui/lib/utils"; import { scrollPageToTop } from "@/lib/page-scroll-to-top"; +import { resolveWindowTopLeftReserveClassName } from "./windowTopLeftReserve"; export const HEADER_ICON_BUTTON_CLASS = COARSE_POINTER_HEADER_ICON_BUTTON_CLASS; @@ -66,13 +65,13 @@ export function AppPageHeader({ desktopInfo, windowState: desktopWindowState, }); - const windowTopLeftReserveClassName = - ownsWindowTopLeft && (isCompactViewport || !isSidebarShowing) - ? !sidebarKeepsCollapsedRail && - (reserveMacosTrafficLights - ? MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS - : BROWSER_COLLAPSED_HEADER_RESERVE_CLASS) - : false; + const windowTopLeftReserveClassName = resolveWindowTopLeftReserveClassName({ + isCompactViewport, + isSidebarShowing, + ownsWindowTopLeft, + reserveMacosTrafficLights, + sidebarKeepsCollapsedRail, + }); return (
{ + it.each([ + { + name: "reserves the browser trigger slot when the sidebar slides fully away", + input: desktopHidden, + expected: BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, + }, + { + name: "reserves nothing beside a collapsed rail, which keeps the trigger", + input: { ...desktopHidden, sidebarKeepsCollapsedRail: true }, + expected: null, + }, + { + name: "reserves nothing beside a collapsed rail on macOS, where the title bar hosts the lights", + input: { + ...desktopHidden, + reserveMacosTrafficLights: true, + sidebarKeepsCollapsedRail: true, + }, + expected: null, + }, + { + name: "keeps the full macOS reserve when no rail stays behind", + input: { ...desktopHidden, reserveMacosTrafficLights: true }, + expected: MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, + }, + { + name: "still reserves the trigger slot on compact viewports while the drawer shows", + input: { + ...desktopHidden, + isCompactViewport: true, + isSidebarShowing: true, + }, + expected: BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, + }, + { + name: "reserves nothing while the desktop sidebar is showing", + input: { ...desktopHidden, isSidebarShowing: true }, + expected: null, + }, + { + name: "reserves nothing for a header that does not own the window corner", + input: { ...desktopHidden, ownsWindowTopLeft: false }, + expected: null, + }, + ])("$name", ({ input, expected }) => { + expect(resolveWindowTopLeftReserveClassName(input)).toBe(expected); + }); +}); diff --git a/apps/app/src/components/layout/windowTopLeftReserve.ts b/apps/app/src/components/layout/windowTopLeftReserve.ts new file mode 100644 index 00000000000..dd3fef8298d --- /dev/null +++ b/apps/app/src/components/layout/windowTopLeftReserve.ts @@ -0,0 +1,28 @@ +import { + BROWSER_COLLAPSED_HEADER_RESERVE_CLASS, + MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, +} from "@/lib/bb-desktop"; + +export function resolveWindowTopLeftReserveClassName({ + isCompactViewport, + isSidebarShowing, + ownsWindowTopLeft, + reserveMacosTrafficLights, + sidebarKeepsCollapsedRail, +}: { + isCompactViewport: boolean; + isSidebarShowing: boolean; + ownsWindowTopLeft: boolean; + reserveMacosTrafficLights: boolean; + sidebarKeepsCollapsedRail: boolean; +}): string | null { + if (!ownsWindowTopLeft || (!isCompactViewport && isSidebarShowing)) { + return null; + } + if (sidebarKeepsCollapsedRail) { + return null; + } + return reserveMacosTrafficLights + ? MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS + : BROWSER_COLLAPSED_HEADER_RESERVE_CLASS; +} diff --git a/apps/app/src/components/machines/MachineLabel.test.ts b/apps/app/src/components/machines/MachineLabel.test.ts new file mode 100644 index 00000000000..201f9fb241f --- /dev/null +++ b/apps/app/src/components/machines/MachineLabel.test.ts @@ -0,0 +1,40 @@ +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { isBuiltinIconName } from "@bb/shared-ui/icon"; +import { describe, expect, it } from "vitest"; +import { resolveMachineIconProvider } from "./MachineLabel"; + +describe("resolveMachineIconProvider", () => { + it("uses a provider fallback, not the laptop glyph or another provider's presentation, when an ephemeral machine's provider is not loaded", () => { + const provider = resolveMachineIconProvider( + makeHost({ + name: "Sandbox abc123", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }), + { + id: "other-provider", + displayName: "Other", + icon: "Cloud", + logoUrl: null, + }, + ); + + expect(provider).not.toBeNull(); + expect(provider?.id).toBe("modal-sandbox"); + expect(provider?.icon).not.toBe("Cloud"); + }); + + it("uses a registered icon for an ephemeral machine whose provider is not loaded", () => { + const provider = resolveMachineIconProvider( + makeHost({ + name: "Sandbox ugxe6e", + type: "ephemeral", + machineProviderId: "unloaded-provider", + }), + undefined, + ); + + expect(provider?.icon).toBe("ComputerCloud"); + expect(isBuiltinIconName(provider?.icon ?? "")).toBe(true); + }); +}); diff --git a/apps/app/src/components/machines/MachineLabel.test.tsx b/apps/app/src/components/machines/MachineLabel.test.tsx deleted file mode 100644 index fd87123778a..00000000000 --- a/apps/app/src/components/machines/MachineLabel.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// @vitest-environment jsdom - -import { render, screen } from "@testing-library/react"; -import { makeHost } from "@bb/test-helpers/domain-fixtures"; -import { describe, expect, it } from "vitest"; -import { MachineLabel } from "./MachineLabel"; - -describe("MachineLabel", () => { - it("shows a provider fallback, not the laptop glyph or another provider's icon, when an ephemeral machine's provider is not loaded", () => { - const { container } = render( - , - ); - - const icon = container.querySelector("[data-icon]"); - expect(screen.getByText("Sandbox abc123")).toBeTruthy(); - expect(icon).not.toBeNull(); - expect(icon?.getAttribute("data-icon")).not.toBe("Laptop"); - expect(icon?.getAttribute("data-icon")).not.toBe("Cloud"); - }); - - it("uses a registered icon for an ephemeral machine whose provider is not loaded", () => { - const { container } = render( - , - ); - - expect( - container.querySelector('[data-icon="ComputerCloud"]'), - ).not.toBeNull(); - expect(container.querySelector('[data-icon="Zap"]')).toBeNull(); - }); -}); diff --git a/apps/app/src/components/machines/MachineLabel.tsx b/apps/app/src/components/machines/MachineLabel.tsx index 8275766b9e0..ab5365e0b61 100644 --- a/apps/app/src/components/machines/MachineLabel.tsx +++ b/apps/app/src/components/machines/MachineLabel.tsx @@ -11,6 +11,24 @@ export type MachineLabelHost = Pick< "machineProviderId" | "name" | "type" >; +export function resolveMachineIconProvider( + host: MachineLabelHost, + machineProvider: MachineProviderPresentation | null | undefined, +): MachineProviderPresentation | null { + if (host.type !== "ephemeral" || host.machineProviderId === null) { + return null; + } + if (machineProvider?.id === host.machineProviderId) { + return machineProvider; + } + return { + id: host.machineProviderId, + displayName: host.machineProviderId, + icon: "ComputerCloud" satisfies BuiltinIconName, + logoUrl: null, + }; +} + export function MachineIcon({ host, machineProvider, @@ -20,17 +38,7 @@ export function MachineIcon({ machineProvider?: MachineProviderPresentation | null; className?: string; }) { - const provider = - host.type === "ephemeral" && host.machineProviderId !== null - ? machineProvider?.id === host.machineProviderId - ? machineProvider - : { - id: host.machineProviderId, - displayName: host.machineProviderId, - icon: "ComputerCloud" satisfies BuiltinIconName, - logoUrl: null, - } - : null; + const provider = resolveMachineIconProvider(host, machineProvider); if (provider === null) { return ( { - cleanup(); - resetPluginSlotStoreForTest(); -}); - -it("renders an environment logo and reacts to React icon registration and removal", () => { - const { container } = render(); - expect( - container - .querySelector("[data-provider-logo]") - ?.getAttribute("data-provider-logo"), - ).toBe(provider.logoUrl); - act(() => - setPluginSlotRegistrations("environment-git-worktree", { - ...makePluginRegistrationSet(), - providerIcons: [ - { - providerKind: "environment", - providerId: "git-worktree", - icon: () => , - }, - ], - }), - ); - expect( - container.querySelector("[data-test-environment-icon]"), - ).not.toBeNull(); - expect(container.querySelector("[data-provider-logo]")).toBeNull(); - act(() => removePluginSlotRegistrations("environment-git-worktree")); - expect(container.querySelector("[data-provider-logo]")).not.toBeNull(); -}); diff --git a/apps/app/src/components/plugin/PluginInstallJobsHost.test.tsx b/apps/app/src/components/plugin/PluginInstallJobsHost.test.ts similarity index 60% rename from apps/app/src/components/plugin/PluginInstallJobsHost.test.tsx rename to apps/app/src/components/plugin/PluginInstallJobsHost.test.ts index 74859fba3aa..5aca9121895 100644 --- a/apps/app/src/components/plugin/PluginInstallJobsHost.test.tsx +++ b/apps/app/src/components/plugin/PluginInstallJobsHost.test.ts @@ -1,18 +1,16 @@ -// @vitest-environment jsdom - -import { MemoryRouter } from "react-router-dom"; -import { act, cleanup, render } from "@testing-library/react"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { PluginInstallJob } from "@bb/server-contract"; +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import type { + PluginInstallJob, + PluginInstallJobState, +} from "@bb/server-contract"; import { makeInstalledPlugin } from "@/test/fixtures/plugins"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { appToast } from "@/components/ui/app-toast"; import { pluginCatalogSearchQueryKey, - pluginInstallJobsQueryKey, pluginListQueryKey, } from "@/hooks/queries/query-keys"; -import { PluginInstallJobsHost } from "./PluginInstallJobsHost"; +import { reportPluginInstallJobs } from "./PluginInstallJobsHost"; const BASE = { id: "job-1", @@ -22,48 +20,35 @@ const BASE = { const PLUGIN = makeInstalledPlugin({ id: "notes", name: "Notes" }); -beforeEach(() => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify({ jobs: [] }))), - ); -}); - afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); vi.restoreAllMocks(); }); -function renderHost(initial: PluginInstallJob[]) { - const { wrapper, queryClient } = createQueryClientTestHarness({ - queries: { staleTime: Infinity }, - }); - queryClient.setQueryData(pluginInstallJobsQueryKey(), initial); - render( - - - , - { wrapper }, - ); +function createReporter() { + const queryClient = new QueryClient(); + const seenStates = new Map(); return { queryClient, publish: (jobs: PluginInstallJob[]) => - act(async () => { - queryClient.setQueryData(pluginInstallJobsQueryKey(), jobs); - await new Promise((resolve) => setTimeout(resolve, 0)); + reportPluginInstallJobs({ + jobs, + seenStates, + queryClient, + cancelJob: () => {}, + action: () => ({ label: "Details", onClick: () => {} }), }), }; } -it("replaces the progress toast with the result once a watched install finishes", async () => { +it("replaces the progress toast with the result once a watched install finishes", () => { const loading = vi.spyOn(appToast, "loading").mockReturnValue("toast"); const success = vi.spyOn(appToast, "success").mockReturnValue("toast"); - const { queryClient, publish } = renderHost([{ ...BASE, state: "running" }]); + const { queryClient, publish } = createReporter(); + publish([{ ...BASE, state: "running" }]); queryClient.setQueryData(pluginListQueryKey(true), []); queryClient.setQueryData(pluginCatalogSearchQueryKey(""), []); - await publish([{ ...BASE, state: "succeeded", plugin: PLUGIN }]); + publish([{ ...BASE, state: "succeeded", plugin: PLUGIN }]); expect(loading).toHaveBeenCalledWith( "Installing plugin…", @@ -79,28 +64,28 @@ it("replaces the progress toast with the result once a watched install finishes" ).toBe(true); }); -it("stays quiet about installs that finished before it was watching", async () => { +it("stays quiet about installs that finished before it was watching", () => { const success = vi.spyOn(appToast, "success").mockReturnValue("toast"); const error = vi.spyOn(appToast, "error").mockReturnValue("toast"); - const { publish } = renderHost([ + const { publish } = createReporter(); + const finished: PluginInstallJob[] = [ { ...BASE, state: "succeeded", plugin: PLUGIN }, { ...BASE, id: "job-2", state: "failed", error: "boom" }, - ]); + ]; - await publish([ - { ...BASE, state: "succeeded", plugin: PLUGIN }, - { ...BASE, id: "job-2", state: "failed", error: "boom" }, - ]); + publish(finished); + publish([...finished]); expect(success).not.toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); }); -it("reports each failure and cancellation once", async () => { +it("reports each failure and cancellation once", () => { vi.spyOn(appToast, "loading").mockReturnValue("toast"); const error = vi.spyOn(appToast, "error").mockReturnValue("toast"); const message = vi.spyOn(appToast, "message").mockReturnValue("toast"); - const { publish } = renderHost([ + const { publish } = createReporter(); + publish([ { ...BASE, state: "running" }, { ...BASE, id: "job-2", state: "queued" }, ]); @@ -109,8 +94,8 @@ it("reports each failure and cancellation once", async () => { { ...BASE, state: "failed", error: "npm install failed" }, { ...BASE, id: "job-2", state: "cancelled" }, ]; - await publish(finished); - await publish([...finished]); + publish(finished); + publish([...finished]); expect(error).toHaveBeenCalledTimes(1); expect(error.mock.calls[0]?.[0]).toBe("Plugin installation failed"); diff --git a/apps/app/src/components/plugin/PluginInstallJobsHost.tsx b/apps/app/src/components/plugin/PluginInstallJobsHost.tsx index 841a77a09a3..58db94f42a0 100644 --- a/apps/app/src/components/plugin/PluginInstallJobsHost.tsx +++ b/apps/app/src/components/plugin/PluginInstallJobsHost.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { PluginInstallJob, PluginInstallJobState, @@ -33,6 +33,69 @@ function progressTitle(job: ActivePluginInstallJob): string { } } +export function reportPluginInstallJobs({ + jobs, + seenStates, + queryClient, + cancelJob, + action, +}: { + jobs: readonly PluginInstallJob[]; + seenStates: Map; + queryClient: QueryClient; + cancelJob: (jobId: string) => void; + action: ReturnType; +}): void { + for (const job of jobs) { + const previous = seenStates.get(job.id); + seenStates.set(job.id, job.state); + if (previous === job.state) continue; + const id = toastId(job); + if (isActivePluginInstallJob(job)) { + appToast.loading(progressTitle(job), { + id, + description: job.displayName, + ...(job.state === "cancelling" + ? {} + : { + action: { + label: "Cancel", + onClick: (event) => { + event.preventDefault(); + cancelJob(job.id); + }, + }, + }), + }); + continue; + } + if (previous === undefined) continue; + if (job.state === "succeeded") { + applyInstalledPlugin({ queryClient, plugin: job.plugin }); + void invalidatePluginList({ queryClient }); + invalidatePluginCatalogSearch({ queryClient }); + appToast.success("Plugin installed", { + id, + description: job.plugin.name ?? job.plugin.id, + action: action(job.plugin.id, "app"), + }); + } else if (job.state === "failed") { + appToast.error("Plugin installation failed", { + id, + description: `${job.displayName} — ${job.error}`, + ...(job.target.kind === "catalog" + ? { action: action(job.target.entryId, "catalog") } + : {}), + }); + } else { + appToast.message("Plugin install cancelled", { + id, + description: job.displayName, + }); + } + } +} + export function PluginInstallJobsHost() { const action = usePluginNotificationAction(); const queryClient = useQueryClient(); @@ -42,54 +105,13 @@ export function PluginInstallJobsHost() { useEffect(() => { if (jobs === undefined) return; - for (const job of jobs) { - const previous = seenStates.current.get(job.id); - seenStates.current.set(job.id, job.state); - if (previous === job.state) continue; - const id = toastId(job); - if (isActivePluginInstallJob(job)) { - appToast.loading(progressTitle(job), { - id, - description: job.displayName, - ...(job.state === "cancelling" - ? {} - : { - action: { - label: "Cancel", - onClick: (event) => { - event.preventDefault(); - cancelJob(job.id); - }, - }, - }), - }); - continue; - } - if (previous === undefined) continue; - if (job.state === "succeeded") { - applyInstalledPlugin({ queryClient, plugin: job.plugin }); - void invalidatePluginList({ queryClient }); - invalidatePluginCatalogSearch({ queryClient }); - appToast.success("Plugin installed", { - id, - description: job.plugin.name ?? job.plugin.id, - action: action(job.plugin.id, "app"), - }); - } else if (job.state === "failed") { - appToast.error("Plugin installation failed", { - id, - description: `${job.displayName} — ${job.error}`, - ...(job.target.kind === "catalog" - ? { action: action(job.target.entryId, "catalog") } - : {}), - }); - } else { - appToast.message("Plugin install cancelled", { - id, - description: job.displayName, - }); - } - } + reportPluginInstallJobs({ + jobs, + seenStates: seenStates.current, + queryClient, + cancelJob, + action, + }); }, [cancelJob, jobs, queryClient, action]); return null; diff --git a/apps/app/src/components/plugin/PluginUpdateJobsHost.test.ts b/apps/app/src/components/plugin/PluginUpdateJobsHost.test.ts new file mode 100644 index 00000000000..a2ec6d2962e --- /dev/null +++ b/apps/app/src/components/plugin/PluginUpdateJobsHost.test.ts @@ -0,0 +1,62 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createMemoryStorage } from "@bb/test-helpers"; +import type { PluginUpdateJob } from "@bb/server-contract"; +import { appToast } from "@/components/ui/app-toast"; +import { reportPluginUpdateJobs } from "./PluginUpdateJobsHost"; + +const base = { id: "update-1", pluginId: "notes", displayName: "Notes" }; + +beforeEach(() => { + vi.stubGlobal("sessionStorage", createMemoryStorage()); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function mount() { + const queryClient = new QueryClient(); + const seen = new Map(); + return (jobs: PluginUpdateJob[]) => + reportPluginUpdateJobs({ + jobs, + seen, + queryClient, + action: () => ({ label: "Details", onClick: () => {} }), + }); +} + +it.each(["updated", "rolled-back"] as const)( + "recovers a watched update after reload and reports %s once", + (outcome) => { + const loading = vi.spyOn(appToast, "loading").mockReturnValue("toast"); + const success = vi.spyOn(appToast, "success").mockReturnValue("toast"); + const error = vi.spyOn(appToast, "error").mockReturnValue("toast"); + mount()([{ ...base, state: "running", phase: "checking" }]); + expect(loading).toHaveBeenCalledWith( + "Updating plugin…", + expect.objectContaining({ id: "plugin-update:update-1" }), + ); + const completed: PluginUpdateJob = { + ...base, + state: "completed", + result: { + applied: outcome === "updated", + from: { version: "1", display: "1" }, + outcome, + }, + }; + const publish = mount(); + publish([completed]); + const toast = outcome === "updated" ? success : error; + expect(toast).toHaveBeenCalledWith( + outcome === "updated" ? "Plugin updated" : "Plugin update failed", + expect.objectContaining({ id: "plugin-update:update-1" }), + ); + publish([{ ...completed }]); + mount()([completed]); + expect(toast).toHaveBeenCalledOnce(); + }, +); diff --git a/apps/app/src/components/plugin/PluginUpdateJobsHost.test.tsx b/apps/app/src/components/plugin/PluginUpdateJobsHost.test.tsx deleted file mode 100644 index 1015c95e85b..00000000000 --- a/apps/app/src/components/plugin/PluginUpdateJobsHost.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// @vitest-environment jsdom - -import { MemoryRouter } from "react-router-dom"; -import { act, cleanup, render } from "@testing-library/react"; -import { afterEach, expect, it, vi } from "vitest"; -import type { PluginUpdateJob } from "@bb/server-contract"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { pluginUpdateJobsQueryKey } from "@/hooks/queries/query-keys"; -import { appToast } from "@/components/ui/app-toast"; -import { PluginUpdateJobsHost } from "./PluginUpdateJobsHost"; - -const base = { id: "update-1", pluginId: "notes", displayName: "Notes" }; - -afterEach(() => { - cleanup(); - sessionStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function mount(jobs: PluginUpdateJob[]) { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify({ jobs }))), - ); - const harness = createQueryClientTestHarness({ - queries: { staleTime: Infinity }, - }); - harness.queryClient.setQueryData(pluginUpdateJobsQueryKey(), jobs); - const view = render( - - - , - { wrapper: harness.wrapper }, - ); - return { ...view, ...harness }; -} - -it.each(["updated", "rolled-back"] as const)( - "recovers a watched update after reload and reports %s once", - async (outcome) => { - const loading = vi.spyOn(appToast, "loading").mockReturnValue("toast"); - const success = vi.spyOn(appToast, "success").mockReturnValue("toast"); - const error = vi.spyOn(appToast, "error").mockReturnValue("toast"); - const first = mount([{ ...base, state: "running", phase: "checking" }]); - expect(loading).toHaveBeenCalledWith( - "Updating plugin…", - expect.objectContaining({ id: "plugin-update:update-1" }), - ); - first.unmount(); - first.queryClient.clear(); - const completed: PluginUpdateJob = { - ...base, - state: "completed", - result: { - applied: outcome === "updated", - from: { version: "1", display: "1" }, - outcome, - }, - }; - const second = mount([completed]); - const toast = outcome === "updated" ? success : error; - expect(toast).toHaveBeenCalledWith( - outcome === "updated" ? "Plugin updated" : "Plugin update failed", - expect.objectContaining({ id: "plugin-update:update-1" }), - ); - await act(async () => { - second.queryClient.setQueryData(pluginUpdateJobsQueryKey(), [completed]); - }); - expect(toast).toHaveBeenCalledOnce(); - second.unmount(); - second.queryClient.clear(); - }, -); diff --git a/apps/app/src/components/plugin/PluginUpdateJobsHost.tsx b/apps/app/src/components/plugin/PluginUpdateJobsHost.tsx index d347c53ed96..3debc25602f 100644 --- a/apps/app/src/components/plugin/PluginUpdateJobsHost.tsx +++ b/apps/app/src/components/plugin/PluginUpdateJobsHost.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import type { PluginUpdateJob } from "@bb/server-contract"; import { usePluginUpdateJobs } from "@/hooks/queries/plugin-update-job-queries"; import { invalidatePluginList, @@ -12,56 +13,75 @@ import { import { appToast } from "@/components/ui/app-toast"; import { usePluginNotificationAction } from "./PluginNotificationDescription"; +export function reportPluginUpdateJobs({ + jobs, + seen, + queryClient, + action, +}: { + jobs: readonly PluginUpdateJob[]; + seen: Map; + queryClient: QueryClient; + action: ReturnType; +}): void { + for (const job of jobs) { + const state = job.state; + const previous = seen.get(job.id); + if (previous === state) continue; + seen.set(job.id, state); + const id = `plugin-update:${job.id}`; + if (job.state === "queued" || job.state === "running") { + trackPluginUpdate(job.id, true); + appToast.loading( + job.state === "queued" ? "Plugin update queued" : "Updating plugin…", + { + id, + description: job.displayName, + }, + ); + continue; + } + if (previous === undefined && !isWatchedPluginUpdate(job.id)) continue; + trackPluginUpdate(job.id, false); + void invalidatePluginList({ queryClient }); + invalidatePluginCatalogSearch({ queryClient }); + if (job.state === "failed") { + appToast.error("Plugin update failed", { + id, + description: `${job.displayName} — ${job.error}`, + action: action(job.pluginId, "installed"), + }); + } else if (job.result.outcome === "rolled-back") { + appToast.error("Plugin update failed", { + id, + description: `${job.displayName} — Previous version and data restored.`, + action: action(job.pluginId, "installed"), + }); + } else { + appToast.success( + job.result.applied ? "Plugin updated" : "Plugin is up to date", + { + id, + description: job.displayName, + action: action(job.pluginId, "app"), + }, + ); + } + } +} + export function PluginUpdateJobsHost() { const action = usePluginNotificationAction(); const queryClient = useQueryClient(); const { data: jobs } = usePluginUpdateJobs(); const seen = useRef(new Map()); useEffect(() => { - for (const job of jobs ?? []) { - const state = job.state; - const previous = seen.current.get(job.id); - if (previous === state) continue; - seen.current.set(job.id, state); - const id = `plugin-update:${job.id}`; - if (job.state === "queued" || job.state === "running") { - trackPluginUpdate(job.id, true); - appToast.loading( - job.state === "queued" ? "Plugin update queued" : "Updating plugin…", - { - id, - description: job.displayName, - }, - ); - continue; - } - if (previous === undefined && !isWatchedPluginUpdate(job.id)) continue; - trackPluginUpdate(job.id, false); - void invalidatePluginList({ queryClient }); - invalidatePluginCatalogSearch({ queryClient }); - if (job.state === "failed") { - appToast.error("Plugin update failed", { - id, - description: `${job.displayName} — ${job.error}`, - action: action(job.pluginId, "installed"), - }); - } else if (job.result.outcome === "rolled-back") { - appToast.error("Plugin update failed", { - id, - description: `${job.displayName} — Previous version and data restored.`, - action: action(job.pluginId, "installed"), - }); - } else { - appToast.success( - job.result.applied ? "Plugin updated" : "Plugin is up to date", - { - id, - description: job.displayName, - action: action(job.pluginId, "app"), - }, - ); - } - } + reportPluginUpdateJobs({ + jobs: jobs ?? [], + seen: seen.current, + queryClient, + action, + }); }, [jobs, queryClient, action]); return null; } diff --git a/apps/app/src/components/plugin/ProviderIcon.test.tsx b/apps/app/src/components/plugin/ProviderIcon.test.tsx index 99648d9fc79..131691669e4 100644 --- a/apps/app/src/components/plugin/ProviderIcon.test.tsx +++ b/apps/app/src/components/plugin/ProviderIcon.test.tsx @@ -12,6 +12,7 @@ import { setPluginSlotRegistrations, } from "@/lib/plugin-slots"; import { pluginSdkAppImplementation } from "@/lib/plugin-sdk-app-impl"; +import { EnvironmentProviderIcon } from "./EnvironmentProviderIcon"; const ProviderIcon = pluginSdkAppImplementation.experimental_ProviderIcon; @@ -119,6 +120,42 @@ it("renders the caller's fallback glyph when the provider declares none", () => expect(view.container.querySelector("[data-override]")).toBeNull(); }); +it("resolves environment provider overrides through EnvironmentProviderIcon", () => { + register((app) => + app.slots.experimental_providerIcon({ + providerKind: "environment", + providerId: "git-worktree", + icon: () => , + }), + ); + const view = render( + , + ); + expect( + view.container.querySelector('[data-override="git-worktree"]'), + ).not.toBeNull(); +}); + it("remounts the same provider component on reload and restores fallback on unload", () => { let mounts = 0; function Mark() { diff --git a/apps/app/src/components/plugin/usePluginBranchPickerState.source-host.test.ts b/apps/app/src/components/plugin/usePluginBranchPickerState.source-host.test.ts new file mode 100644 index 00000000000..bbaf6c77019 --- /dev/null +++ b/apps/app/src/components/plugin/usePluginBranchPickerState.source-host.test.ts @@ -0,0 +1,47 @@ +import type { ProjectSource } from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { resolvePluginBranchHostId } from "./usePluginBranchPickerState"; + +function source(hostId: string, isDefault: boolean): ProjectSource { + return { + id: `source-${hostId}`, + projectId: "project-1", + type: "local_path", + hostId, + path: `/repo/${hostId}`, + isDefault, + createdAt: 0, + updatedAt: 0, + }; +} + +describe("resolvePluginBranchHostId", () => { + it.each([ + { + name: "keeps an explicit host", + hostId: "host-1", + sources: [source("source-host", true)], + expected: "host-1", + }, + { + name: "uses the default project source before a host exists", + hostId: null, + sources: [source("other-host", false), source("source-host", true)], + expected: "source-host", + }, + { + name: "falls back to the first local source without a default", + hostId: null, + sources: [source("first-host", false), source("second-host", false)], + expected: "first-host", + }, + { + name: "has no host without sources", + hostId: null, + sources: undefined, + expected: null, + }, + ])("$name", ({ hostId, sources, expected }) => { + expect(resolvePluginBranchHostId(hostId, sources)).toBe(expected); + }); +}); diff --git a/apps/app/src/components/plugin/usePluginBranchPickerState.test.tsx b/apps/app/src/components/plugin/usePluginBranchPickerState.test.tsx index 19771f4184b..c71eddb25fa 100644 --- a/apps/app/src/components/plugin/usePluginBranchPickerState.test.tsx +++ b/apps/app/src/components/plugin/usePluginBranchPickerState.test.tsx @@ -9,18 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { usePluginBranches } from "./usePluginBranchPickerState"; vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useSidebarNavigation: () => ({ - data: { - projects: [ - { - id: "project-1", - sources: [ - { type: "local_path", isDefault: true, hostId: "source-host" }, - ], - }, - ], - }, - }), + useSidebarNavigation: () => ({ data: undefined }), })); vi.mock("@/lib/sdk", () => ({ @@ -57,22 +46,6 @@ describe("usePluginBranches", () => { vi.clearAllMocks(); }); - it("uses the project source for branch suggestions before a host exists", async () => { - const { wrapper } = createQueryClientTestHarness(); - vi.mocked(readProjectBranchOptions).mockResolvedValue(BRANCHES); - const { result } = renderHook( - () => usePluginBranches({ hostId: null, projectId: "project-1" }), - { wrapper }, - ); - await waitFor(() => expect(result.current.branches).toEqual(["release"])); - expect(readProjectBranchOptions).toHaveBeenCalledWith( - expect.objectContaining({ - hostId: "source-host", - projectId: "project-1", - }), - ); - }); - it("searches cached branches and refreshes them from the host", async () => { const { wrapper } = createQueryClientTestHarness(); vi.mocked(readProjectBranchOptions).mockResolvedValueOnce(BRANCHES); diff --git a/apps/app/src/components/plugin/usePluginBranchPickerState.ts b/apps/app/src/components/plugin/usePluginBranchPickerState.ts index 88f08086d80..e2484de6174 100644 --- a/apps/app/src/components/plugin/usePluginBranchPickerState.ts +++ b/apps/app/src/components/plugin/usePluginBranchPickerState.ts @@ -1,3 +1,4 @@ +import type { ProjectSource } from "@bb/domain"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useCallback, useMemo } from "react"; import { useProjectSourceBranches } from "@/hooks/queries/project-queries"; @@ -8,6 +9,17 @@ import type { UseCheckoutStateArgs, } from "@get-bb/plugin-sdk"; +export function resolvePluginBranchHostId( + hostId: string | null, + sources: readonly ProjectSource[] | undefined, +): string | null { + const source = + sources?.find( + (source) => source.type === "local_path" && source.isDefault, + ) ?? sources?.find((source) => source.type === "local_path"); + return hostId ?? source?.hostId ?? null; +} + export function usePluginBranches({ hostId, projectId, @@ -16,14 +28,11 @@ export function usePluginBranches({ const navigation = useSidebarNavigation({ enabled: hostId === null && projectId !== null, }); - const sources = navigation.data?.projects.find( - (project) => project.id === projectId, - )?.sources; - const source = - sources?.find( - (source) => source.type === "local_path" && source.isDefault, - ) ?? sources?.find((source) => source.type === "local_path"); - const branchHostId = hostId ?? source?.hostId ?? null; + const branchHostId = resolvePluginBranchHostId( + hostId, + navigation.data?.projects.find((project) => project.id === projectId) + ?.sources, + ); const enabled = branchHostId !== null && projectId !== null; const branchesQuery = useProjectSourceBranches( projectId ?? undefined, diff --git a/apps/app/src/components/promptbox/banner/ProviderCliBanner.test.tsx b/apps/app/src/components/promptbox/banner/ProviderCliBanner.test.tsx index df8fcd11151..a2e8a9f6d6e 100644 --- a/apps/app/src/components/promptbox/banner/ProviderCliBanner.test.tsx +++ b/apps/app/src/components/promptbox/banner/ProviderCliBanner.test.tsx @@ -1,88 +1,67 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; import { ProviderCliBanner } from "./ProviderCliBanner"; -afterEach(() => { - cleanup(); -}); +function renderBanner( + props: Omit, "onAction">, +) { + const markup = renderToStaticMarkup( + {}} />, + ); + return { + markup, + text: markup.replace(/<[^>]+>/g, ""), + button: markup.match(/]*)>(.*?)<\/button>/), + }; +} describe("ProviderCliBanner", () => { it("uses the selected provider's identity and update requirement", () => { - const onAction = vi.fn(); - render( - , - ); + const { markup, text, button } = renderBanner({ + displayName: "Example Agent", + installed: true, + currentVersion: "0.135.0", + minimumSupportedVersion: "0.136.0", + canRunAction: true, + actionRunning: false, + }); - expect( - screen.getByRole("region", { name: "Example Agent update required" }), - ).toBeTruthy(); - expect(screen.getByRole("alert").textContent).toContain( + expect(markup).toContain('aria-label="Example Agent update required"'); + expect(text).toContain( "Update Example Agent before starting a thread. Installed 0.135.0; version 0.136.0 or newer is required.", ); - - fireEvent.click( - screen.getByRole("button", { name: "Update Example Agent" }), - ); - expect(onAction).toHaveBeenCalledOnce(); + expect(button?.[2]).toBe("Update Example Agent"); + expect(button?.[1]).not.toContain('disabled=""'); }); it("shows update progress without repeating an ambiguous version fallback", () => { - render( - , - ); + const { text, button } = renderBanner({ + displayName: "Codex", + installed: true, + currentVersion: "0.135.0", + minimumSupportedVersion: null, + canRunAction: true, + actionRunning: true, + }); - expect(screen.getByRole("alert").textContent).toContain( - "Installed 0.135.0; a newer version is required.", - ); - expect( - ( - screen.getByRole("button", { - name: "Updating…", - }) as HTMLButtonElement - ).disabled, - ).toBe(true); + expect(text).toContain("Installed 0.135.0; a newer version is required."); + expect(button?.[2]).toContain("Updating…"); + expect(button?.[1]).toContain('disabled=""'); }); it("asks for an install instead of a version when the CLI is missing", () => { - const onAction = vi.fn(); - render( - , - ); + const { markup, text, button } = renderBanner({ + displayName: "Claude Code", + installed: false, + currentVersion: null, + minimumSupportedVersion: "2.1.0", + canRunAction: true, + actionRunning: false, + }); - expect( - screen.getByRole("region", { name: "Claude Code not installed" }), - ).toBeTruthy(); - expect(screen.getByRole("alert").textContent).not.toContain("version"); - - fireEvent.click( - screen.getByRole("button", { name: "Install Claude Code" }), - ); - expect(onAction).toHaveBeenCalledOnce(); + expect(markup).toContain('aria-label="Claude Code not installed"'); + expect(text).not.toContain("version"); + expect(button?.[2]).toBe("Install Claude Code"); }); }); diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.drag.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.drag.test.tsx deleted file mode 100644 index 4ab4d372b91..00000000000 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.drag.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @vitest-environment jsdom - -import { fireEvent, render, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadQueuedMessage } from "@bb/domain"; -import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; -import { QueuedMessagesList } from "./QueuedMessagesList"; - -const noop = () => {}; - -function makeQueuedMessage(id: string, text: string): ThreadQueuedMessage { - return makeThreadQueuedMessage({ - id, - threadId: "thr_queue", - content: [{ type: "text", text, mentions: [] }], - }); -} - -function rect({ top, bottom }: { top: number; bottom: number }) { - return new DOMRect(0, top, 100, bottom - top); -} - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("QueuedMessagesList group-handle drag", () => { - it("drags the zero-height group handle to a measured row stroke", async () => { - const onSetGroupBoundary = vi.fn(); - const queuedMessages = [ - makeQueuedMessage("q_one", "First queued message"), - makeQueuedMessage("q_two", "Second queued message"), - makeQueuedMessage("q_three", "Third queued message"), - ]; - const { container, getByLabelText } = render( - , - ); - const rows = container.querySelectorAll( - "[data-queued-message-row]", - ); - const divider = container.querySelector( - "[data-queued-message-group-divider]", - ); - const list = container.querySelector("ul"); - const scroll = container.querySelector( - "[data-queued-messages-scroll]", - ); - expect(divider).not.toBeNull(); - expect(list).not.toBeNull(); - expect(scroll).not.toBeNull(); - - const measuredRects = [ - rect({ top: 0, bottom: 40 }), - rect({ top: 40, bottom: 72 }), - rect({ top: 72, bottom: 112 }), - ]; - rows.forEach((row, index) => { - row.getBoundingClientRect = () => measuredRects[index]!; - }); - divider!.getBoundingClientRect = () => rect({ top: 40, bottom: 40 }); - list!.getBoundingClientRect = () => rect({ top: 0, bottom: 116 }); - scroll!.getBoundingClientRect = () => rect({ top: 0, bottom: 160 }); - - const handle = getByLabelText("Messages above send together"); - fireEvent.pointerDown(handle, { - button: 0, - clientX: 50, - clientY: 40, - isPrimary: true, - pointerId: 1, - }); - fireEvent.pointerMove(document, { - clientX: 50, - clientY: 46, - isPrimary: true, - pointerId: 1, - }); - fireEvent.pointerMove(document, { - clientX: 50, - clientY: 108, - isPrimary: true, - pointerId: 1, - }); - fireEvent.pointerUp(document, { - clientX: 50, - clientY: 108, - isPrimary: true, - pointerId: 1, - }); - - await waitFor(() => - expect(onSetGroupBoundary).toHaveBeenCalledWith({ - expectedGroupedPrefixQueuedMessageIds: ["q_one", "q_two", "q_three"], - groupBoundaryQueuedMessageId: "q_three", - }), - ); - }); -}); diff --git a/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx index cefc7330f52..0252b0ab832 100644 --- a/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx @@ -1,7 +1,5 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; import { ThreadMachineStatusBanner } from "./ThreadMachineStatus"; const provider = { @@ -16,53 +14,48 @@ const provider = { supportsSuspend: true, }; -afterEach(cleanup); +function renderBanner(phase: "suspending" | "suspended" | "resuming") { + const markup = renderToStaticMarkup( + {}} + />, + ); + return { + markup, + status: markup + .match(/role="status">(.*?)<\/div>/)?.[1] + ?.replace(/<[^>]+>/g, ""), + buttons: [...markup.matchAll(/]*)>(.*?)<\/button>/g)], + }; +} describe("ThreadMachineStatusBanner", () => { it("names the paused state generically and keeps the provider icon", () => { - const { container } = render( - , - ); + const { markup, status, buttons } = renderBanner("suspended"); - expect(screen.getByRole("status").textContent).toBe("Machine is paused"); - expect(container.querySelector('[data-icon="Cloud"]')).not.toBeNull(); - expect(screen.queryByText("Modal sandbox ugxe6e")).toBeNull(); - expect(screen.queryByText(provider.displayName)).toBeNull(); - expect( - (screen.getByRole("button", { name: "Resume" }) as HTMLButtonElement) - .disabled, - ).toBe(false); + expect(status).toBe("Machine is paused"); + expect(markup).toContain('data-icon="Cloud"'); + expect(markup).not.toContain("Modal sandbox ugxe6e"); + expect(markup).not.toContain(provider.displayName); + expect(buttons.map((button) => button[2])).toEqual(["Resume"]); + expect(buttons[0]?.[1]).not.toContain('disabled=""'); }); it.each([ ["suspending", "Machine is pausing…"], ["resuming", "Machine is resuming…"], - ] as const)("renders the durable %s phase", (phase, status) => { - render( - , - ); + ] as const)("renders the durable %s phase", (phase, expected) => { + const { status, buttons } = renderBanner(phase); - expect(screen.getByRole("status").textContent).toBe(status); - expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); + expect(status).toBe(expected); + expect(buttons).toEqual([]); }); }); diff --git a/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.ts b/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.ts new file mode 100644 index 00000000000..a5869c9e6c1 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.ts @@ -0,0 +1,66 @@ +import type { ThreadQueuedMessage } from "@bb/domain"; +import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; +import { describe, expect, it } from "vitest"; +import { + isQueueExpanded, + nextCollapsedQueues, + setQueueExpanded, +} from "./queued-messages-expanded"; + +const firstMessage = makeThreadQueuedMessage({ id: "q_first" }); +const secondMessage = makeThreadQueuedMessage({ id: "q_second" }); + +function createQueues() { + let collapsed: ReadonlyMap> = new Map(); + return { + show( + threadId: string, + queuedMessages: readonly ThreadQueuedMessage[] | null, + ) { + if (queuedMessages !== null) { + collapsed = nextCollapsedQueues(collapsed, threadId, queuedMessages); + } + return isQueueExpanded(collapsed, threadId, queuedMessages); + }, + close(threadId: string, queuedMessages: readonly ThreadQueuedMessage[]) { + collapsed = setQueueExpanded(collapsed, threadId, false, queuedMessages); + }, + }; +} + +describe("queued messages expanded state", () => { + it("opens each thread's queue until that thread's queue is closed", () => { + const queues = createQueues(); + expect(queues.show("thr_a", [firstMessage])).toBe(true); + + queues.close("thr_a", [firstMessage]); + expect(queues.show("thr_a", [firstMessage])).toBe(false); + expect(queues.show("thr_b", [secondMessage])).toBe(true); + expect(queues.show("thr_a", [firstMessage])).toBe(false); + }); + + it("keeps a closed queue closed while any message it has held remains", () => { + const queues = createQueues(); + queues.close("thr_a", [firstMessage]); + + queues.show("thr_a", [firstMessage, secondMessage]); + expect(queues.show("thr_a", [secondMessage])).toBe(false); + expect(queues.show("thr_a", null)).toBe(false); + }); + + it("reopens a closed queue once it empties", () => { + const queues = createQueues(); + queues.close("thr_a", [firstMessage]); + + queues.show("thr_a", []); + expect(queues.show("thr_a", [secondMessage])).toBe(true); + }); + + it("reopens a queue that emptied and refilled while its thread was away", () => { + const collapsed = setQueueExpanded(new Map(), "thr_a", false, [ + firstMessage, + ]); + + expect(isQueueExpanded(collapsed, "thr_a", [secondMessage])).toBe(true); + }); +}); diff --git a/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.tsx b/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.tsx index 78846c3dbd4..32f663bb76b 100644 --- a/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.tsx +++ b/apps/app/src/components/promptbox/banner/queued-messages-expanded.test.tsx @@ -83,66 +83,4 @@ describe("useQueuedMessagesExpanded", () => { expect(desktop.result.current[0]).toBe(true); }); - it("opens each thread's queue until that thread's queue is closed", () => { - const view = renderExpanded({ - threadId: "thr_a", - queuedMessages: [firstMessage], - }); - expect(view.result.current[0]).toBe(true); - - act(() => view.result.current[1](false)); - expect(view.result.current[0]).toBe(false); - - view.rerender({ threadId: "thr_b", queuedMessages: [secondMessage] }); - expect(view.result.current[0]).toBe(true); - - view.rerender({ threadId: "thr_a", queuedMessages: [firstMessage] }); - expect(view.result.current[0]).toBe(false); - }); - - it("keeps a closed queue closed while any message it has held remains", () => { - const view = renderExpanded({ - threadId: "thr_a", - queuedMessages: [firstMessage], - }); - act(() => view.result.current[1](false)); - - view.rerender({ - threadId: "thr_a", - queuedMessages: [firstMessage, secondMessage], - }); - view.rerender({ threadId: "thr_a", queuedMessages: [secondMessage] }); - expect(view.result.current[0]).toBe(false); - - view.rerender({ threadId: "thr_a", queuedMessages: null }); - expect(view.result.current[0]).toBe(false); - }); - - it("reopens a closed queue once it empties", () => { - const view = renderExpanded({ - threadId: "thr_a", - queuedMessages: [firstMessage], - }); - act(() => view.result.current[1](false)); - - view.rerender({ threadId: "thr_a", queuedMessages: [] }); - view.rerender({ threadId: "thr_a", queuedMessages: [secondMessage] }); - expect(view.result.current[0]).toBe(true); - }); - - it("reopens a queue that emptied and refilled while its thread was away", () => { - const store = createStore(); - const view = renderExpanded( - { threadId: "thr_a", queuedMessages: [firstMessage] }, - store, - ); - act(() => view.result.current[1](false)); - view.unmount(); - - const returned = renderExpanded( - { threadId: "thr_a", queuedMessages: [secondMessage] }, - store, - ); - expect(returned.result.current[0]).toBe(true); - }); }); diff --git a/apps/app/src/components/promptbox/banner/queued-messages-expanded.ts b/apps/app/src/components/promptbox/banner/queued-messages-expanded.ts index dd9dccbcf69..31137c5e7d9 100644 --- a/apps/app/src/components/promptbox/banner/queued-messages-expanded.ts +++ b/apps/app/src/components/promptbox/banner/queued-messages-expanded.ts @@ -3,9 +3,11 @@ import { atom, useAtom } from "jotai"; import type { ThreadQueuedMessage } from "@bb/domain"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; -export const queuedMessagesCollapsedQueuesAtom = atom< - ReadonlyMap> ->(new Map>()); +type CollapsedQueues = ReadonlyMap>; + +export const queuedMessagesCollapsedQueuesAtom = atom( + new Map>(), +); const mobileExpandedQueuesAtom = atom>>( new Map>(), @@ -18,6 +20,64 @@ function sharesQueuedMessage( return queuedMessages.some((queuedMessage) => seenIds.has(queuedMessage.id)); } +export function setQueueExpanded( + current: CollapsedQueues, + threadId: string, + expanded: boolean, + queuedMessages: readonly ThreadQueuedMessage[] | null, + defaultExpanded = true, +): CollapsedQueues { + if (expanded === (defaultExpanded !== current.has(threadId))) return current; + const next = new Map(current); + if (expanded === defaultExpanded) { + next.delete(threadId); + } else { + next.set( + threadId, + new Set((queuedMessages ?? []).map((queuedMessage) => queuedMessage.id)), + ); + } + return next; +} + +export function nextCollapsedQueues( + current: CollapsedQueues, + threadId: string, + queuedMessages: readonly ThreadQueuedMessage[], +): CollapsedQueues { + const seenIds = current.get(threadId); + if (seenIds === undefined) return current; + if (!sharesQueuedMessage(queuedMessages, seenIds)) { + const next = new Map(current); + next.delete(threadId); + return next; + } + if (queuedMessages.every((queuedMessage) => seenIds.has(queuedMessage.id))) { + return current; + } + const next = new Map(current); + next.set( + threadId, + new Set([ + ...seenIds, + ...queuedMessages.map((queuedMessage) => queuedMessage.id), + ]), + ); + return next; +} + +export function isQueueExpanded( + collapsedQueues: CollapsedQueues, + threadId: string, + queuedMessages: readonly ThreadQueuedMessage[] | null, +): boolean { + const seenIds = collapsedQueues.get(threadId); + return ( + seenIds === undefined || + (queuedMessages !== null && !sharesQueuedMessage(queuedMessages, seenIds)) + ); +} + export function useQueuedMessagesExpanded({ threadId, queuedMessages, @@ -33,55 +93,18 @@ export function useQueuedMessagesExpanded({ ); const setExpanded = useCallback( (expanded: boolean) => { - setToggledQueues((current) => { - if (expanded === (defaultExpanded !== current.has(threadId))) { - return current; - } - const next = new Map(current); - if (expanded === defaultExpanded) { - next.delete(threadId); - } else { - next.set( - threadId, - new Set( - (queuedMessages ?? []).map((queuedMessage) => queuedMessage.id), - ), - ); - } - return next; - }); + setToggledQueues((current) => + setQueueExpanded(current, threadId, expanded, queuedMessages, defaultExpanded), + ); }, [defaultExpanded, queuedMessages, setToggledQueues, threadId], ); useEffect(() => { if (queuedMessages === null) return; - setToggledQueues((current) => { - const seenIds = current.get(threadId); - if (seenIds === undefined) return current; - if (!sharesQueuedMessage(queuedMessages, seenIds)) { - const next = new Map(current); - next.delete(threadId); - return next; - } - if ( - queuedMessages.every((queuedMessage) => seenIds.has(queuedMessage.id)) - ) { - return current; - } - const next = new Map(current); - next.set( - threadId, - new Set([ - ...seenIds, - ...queuedMessages.map((queuedMessage) => queuedMessage.id), - ]), - ); - return next; - }); + setToggledQueues((current) => + nextCollapsedQueues(current, threadId, queuedMessages), + ); }, [queuedMessages, setToggledQueues, threadId]); - const seenIds = toggledQueues.get(threadId); - const usesDefault = - seenIds === undefined || - (queuedMessages !== null && !sharesQueuedMessage(queuedMessages, seenIds)); + const usesDefault = isQueueExpanded(toggledQueues, threadId, queuedMessages); return [usesDefault ? defaultExpanded : !defaultExpanded, setExpanded]; } diff --git a/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.ts b/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.ts new file mode 100644 index 00000000000..e80e44f7fcc --- /dev/null +++ b/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.ts @@ -0,0 +1,177 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { + EMPTY_TITLE_MENTION_RESOURCES, + resolveSerializedPromptMentions, + type ThreadTitleMentionResources, +} from "@/components/thread/ThreadTitleMentions"; +import type { sdk } from "@/lib/sdk"; +import { + initialPromptDraftQueryOptions, + resolveInitialPromptDraft, +} from "./initial-prompt-draft"; + +const resources: ThreadTitleMentionResources = { + projectNamesById: new Map([["proj_app", "Application"]]), + sectionNamesById: new Map([["sec_work", "Work"]]), + threadById: new Map([ + [ + "thr_known", + { + id: "thr_known", + projectId: "proj_app", + title: "Real title", + titleFallback: "Fallback", + }, + ], + [ + "thr_fallback", + { + id: "thr_fallback", + projectId: "proj_app", + title: null, + titleFallback: "Fallback title", + }, + ], + ]), +}; + +function draft(text: string, source = resources) { + return { + text, + mentions: resolveSerializedPromptMentions(text, source), + attachments: [], + }; +} + +describe("initial prompt mentions", () => { + it("keeps UTF-16 offsets and resolves repeated tokens, titles, names and fallback titles", () => { + const text = + "😀 Hand off @thread:thr_known,\n@project:proj_app @section:sec_work @thread:thr_fallback @thread:thr_known."; + const result = draft(text); + expect(result.text).toBe(text); + expect( + result.mentions.map(({ start, end, resource }) => ({ + start, + end, + token: text.slice(start, end), + label: resource.label, + })), + ).toEqual([ + { start: 12, end: 29, token: "@thread:thr_known", label: "Real title" }, + { start: 31, end: 48, token: "@project:proj_app", label: "Application" }, + { start: 49, end: 66, token: "@section:sec_work", label: "Work" }, + { + start: 67, + end: 87, + token: "@thread:thr_fallback", + label: "Fallback title", + }, + { start: 88, end: 105, token: "@thread:thr_known", label: "Real title" }, + ]); + }); + + it("leaves ordinary text, raw IDs, paths and malformed token boundaries alone", () => { + expect( + draft( + "thr_known @src/app.ts @folder:sec_work user@thread:thr_known @thread:thr_known/path @project: @section:sec_work.json", + ).mentions, + ).toEqual([]); + const text = "@src/app.ts @thread:thr_known"; + expect(draft(text).mentions[0]).toMatchObject({ + start: 12, + end: text.length, + }); + }); + + it("resolves remote threads once and preserves missing-ID fallbacks", async () => { + const lookup = vi + .fn() + .mockResolvedValue([ + { + threadId: "thr_abcdefghij", + projectId: "proj_remote", + label: "Remote title", + }, + ]); + const result = await resolveInitialPromptDraft( + draft( + "@thread:thr_abcdefghij @thread:thr_abcdefghij @thread:thr_23456789ab @project:missing @section:missing", + ), + lookup, + new AbortController().signal, + ); + expect(lookup).toHaveBeenCalledWith({ + threadIds: ["thr_abcdefghij", "thr_23456789ab"], + signal: expect.any(AbortSignal), + }); + expect(result.mentions.map(({ resource }) => resource.label)).toEqual([ + "Remote title", + "Remote title", + "Unavailable thread", + "missing", + "missing", + ]); + expect(result.mentions[0]?.resource).toMatchObject({ + projectId: "proj_remote", + }); + }); + + it("keeps a Thread pill if resolution fails", async () => { + const lookup = vi + .fn() + .mockRejectedValue(new Error("offline")); + const result = await resolveInitialPromptDraft( + draft("@thread:thr_23456789ab"), + lookup, + new AbortController().signal, + ); + expect(result.mentions[0]?.resource.label).toBe("Thread"); + }); + + it("waits for sidebar metadata and discards a replaced seed's late lookup", async () => { + let finish: ( + value: Awaited>, + ) => void = () => {}; + const lookup = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const options = (text: string, navigationPending: boolean) => + initialPromptDraftQueryOptions({ + text, + navigationPending, + draft: draft(text, EMPTY_TITLE_MENTION_RESOURCES), + resolveMentions: lookup, + }); + const client = new QueryClient(); + const observer = new QueryObserver( + client, + options("@thread:thr_abcdefghij", true), + ); + const unsubscribe = observer.subscribe(() => {}); + await Promise.resolve(); + expect(observer.getCurrentResult().data).toBeUndefined(); + expect(lookup).not.toHaveBeenCalled(); + observer.setOptions(options("@thread:thr_abcdefghij", false)); + await vi.waitFor(() => expect(lookup).toHaveBeenCalledOnce()); + observer.setOptions(options("Replacement seed", false)); + await vi.waitFor(() => + expect(observer.getCurrentResult().data?.text).toBe("Replacement seed"), + ); + finish([ + { + threadId: "thr_abcdefghij", + projectId: "proj_app", + label: "Late title", + }, + ]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(observer.getCurrentResult().data?.text).toBe("Replacement seed"); + expect(observer.getCurrentResult().data?.mentions).toEqual([]); + unsubscribe(); + client.clear(); + }); +}); diff --git a/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.tsx b/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.tsx deleted file mode 100644 index 9e3f5a651fc..00000000000 --- a/apps/app/src/components/promptbox/mentions/initial-prompt-draft.test.tsx +++ /dev/null @@ -1,295 +0,0 @@ -// @vitest-environment jsdom - -import { useEffect, type ReactNode } from "react"; -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { emptyPromptDraftState } from "@bb/client-core"; -import { - resolveSerializedPromptMentions, - type ThreadTitleMentionResources, -} from "@/components/thread/ThreadTitleMentions"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; -import { sdk } from "@/lib/sdk"; -import { - resolveInitialPromptDraft, - useInitialPromptDraft, -} from "./initial-prompt-draft"; - -const navigationState = vi.hoisted(() => ({ pending: false })); - -vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useSidebarNavigation: () => ({ - data: undefined, - isPending: navigationState.pending, - }), -})); - -const resources: ThreadTitleMentionResources = { - projectNamesById: new Map([["proj_app", "Application"]]), - sectionNamesById: new Map([["sec_work", "Work"]]), - threadById: new Map([ - [ - "thr_known", - { - id: "thr_known", - projectId: "proj_app", - title: "Real title", - titleFallback: "Fallback", - }, - ], - [ - "thr_fallback", - { - id: "thr_fallback", - projectId: "proj_app", - title: null, - titleFallback: "Fallback title", - }, - ], - ]), -}; - -function draft(text: string, source = resources) { - return { - text, - mentions: resolveSerializedPromptMentions(text, source), - attachments: [], - }; -} - -afterEach(() => { - cleanup(); - navigationState.pending = false; - vi.restoreAllMocks(); - window.localStorage.clear(); -}); - -describe("initial prompt mentions", () => { - it("keeps UTF-16 offsets and resolves repeated tokens, titles, names and fallback titles", () => { - const text = - "😀 Hand off @thread:thr_known,\n@project:proj_app @section:sec_work @thread:thr_fallback @thread:thr_known."; - const result = draft(text); - expect(result.text).toBe(text); - expect( - result.mentions.map(({ start, end, resource }) => ({ - start, - end, - token: text.slice(start, end), - label: resource.label, - })), - ).toEqual([ - { start: 12, end: 29, token: "@thread:thr_known", label: "Real title" }, - { start: 31, end: 48, token: "@project:proj_app", label: "Application" }, - { start: 49, end: 66, token: "@section:sec_work", label: "Work" }, - { - start: 67, - end: 87, - token: "@thread:thr_fallback", - label: "Fallback title", - }, - { start: 88, end: 105, token: "@thread:thr_known", label: "Real title" }, - ]); - }); - - it("leaves ordinary text, raw IDs, paths and malformed token boundaries alone", () => { - expect( - draft( - "thr_known @src/app.ts @folder:sec_work user@thread:thr_known @thread:thr_known/path @project: @section:sec_work.json", - ).mentions, - ).toEqual([]); - const text = "@src/app.ts @thread:thr_known"; - expect(draft(text).mentions[0]).toMatchObject({ - start: 12, - end: text.length, - }); - }); - - it("resolves remote threads once and preserves missing-ID fallbacks", async () => { - const lookup = vi - .fn() - .mockResolvedValue([ - { - threadId: "thr_abcdefghij", - projectId: "proj_remote", - label: "Remote title", - }, - ]); - const result = await resolveInitialPromptDraft( - draft( - "@thread:thr_abcdefghij @thread:thr_abcdefghij @thread:thr_23456789ab @project:missing @section:missing", - ), - lookup, - new AbortController().signal, - ); - expect(lookup).toHaveBeenCalledWith({ - threadIds: ["thr_abcdefghij", "thr_23456789ab"], - signal: expect.any(AbortSignal), - }); - expect(result.mentions.map(({ resource }) => resource.label)).toEqual([ - "Remote title", - "Remote title", - "Unavailable thread", - "missing", - "missing", - ]); - expect(result.mentions[0]?.resource).toMatchObject({ - projectId: "proj_remote", - }); - }); - - it("keeps a Thread pill if resolution fails", async () => { - const lookup = vi - .fn() - .mockRejectedValue(new Error("offline")); - const result = await resolveInitialPromptDraft( - draft("@thread:thr_23456789ab"), - lookup, - new AbortController().signal, - ); - expect(result.mentions[0]?.resource.label).toBe("Thread"); - }); - - it.each([false, true])( - "preserves a non-empty draft when resolution completes (existing: %s)", - async (existing) => { - let finish: ( - value: Awaited>, - ) => void = () => {}; - vi.spyOn(sdk.threads, "resolveMentions").mockImplementation( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - const scope = { - kind: "thread" as const, - projectId: "proj_seed", - threadId: `thr_seed_${existing}`, - }; - const { result, rerender } = renderHook( - ({ seedPrompt }) => { - const storage = usePromptDraftStorage(scope); - const seed = useInitialPromptDraft( - seedPrompt ? "@thread:thr_abcdefghij " : null, - ); - const restore = storage.restoreIfEmpty; - useEffect(() => { - if (seed) restore(seed); - }, [restore, seed]); - return { storage, seed }; - }, - { wrapper, initialProps: { seedPrompt: !existing } }, - ); - act(() => { - result.current.storage.setDraft({ - ...emptyPromptDraftState(), - text: existing ? "Saved draft" : "Typed during lookup", - }); - }); - rerender({ seedPrompt: true }); - await act(async () => { - finish([ - { - threadId: "thr_abcdefghij", - projectId: "proj_app", - label: "Resolved title", - }, - ]); - }); - await waitFor(() => expect(result.current.seed).toBeDefined()); - expect(result.current.storage.text).toBe( - existing ? "Saved draft" : "Typed during lookup", - ); - expect(result.current.storage.mentions).toEqual([]); - client.clear(); - }, - ); - - it("waits for sidebar metadata and discards a replaced seed's late lookup", async () => { - navigationState.pending = true; - let finish: ( - value: Awaited>, - ) => void = () => {}; - const lookup = vi.spyOn(sdk.threads, "resolveMentions").mockImplementation( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const client = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - const { result, rerender } = renderHook( - ({ text }) => useInitialPromptDraft(text), - { - wrapper, - initialProps: { text: "@thread:thr_abcdefghij" }, - }, - ); - expect(result.current).toBeUndefined(); - expect(lookup).not.toHaveBeenCalled(); - navigationState.pending = false; - rerender({ text: "@thread:thr_abcdefghij" }); - await waitFor(() => expect(lookup).toHaveBeenCalledOnce()); - rerender({ text: "Replacement seed" }); - await waitFor(() => expect(result.current?.text).toBe("Replacement seed")); - await act(async () => { - finish([ - { - threadId: "thr_abcdefghij", - projectId: "proj_app", - label: "Late title", - }, - ]); - }); - expect(result.current?.text).toBe("Replacement seed"); - expect(result.current?.mentions).toEqual([]); - client.clear(); - }); - - it("seeds an empty draft with resolved mention ranges", async () => { - vi.spyOn(sdk.threads, "resolveMentions").mockResolvedValue([ - { - threadId: "thr_abcdefghij", - projectId: "proj_app", - label: "Resolved title", - }, - ]); - const client = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - const { result } = renderHook( - () => { - const storage = usePromptDraftStorage({ - kind: "thread", - projectId: "proj_seed", - threadId: "thr_empty", - }); - const seed = useInitialPromptDraft("@thread:thr_abcdefghij "); - const restore = storage.restoreIfEmpty; - useEffect(() => { - if (seed) restore(seed); - }, [restore, seed]); - return storage; - }, - { wrapper }, - ); - await waitFor(() => expect(result.current.mentions).toHaveLength(1)); - expect(result.current.mentions[0]).toMatchObject({ - start: 0, - end: 22, - resource: { label: "Resolved title" }, - }); - expect(result.current.text).toBe("@thread:thr_abcdefghij "); - client.clear(); - }); -}); diff --git a/apps/app/src/components/promptbox/mentions/initial-prompt-draft.ts b/apps/app/src/components/promptbox/mentions/initial-prompt-draft.ts index 091a67cfdb9..544b0c569ed 100644 --- a/apps/app/src/components/promptbox/mentions/initial-prompt-draft.ts +++ b/apps/app/src/components/promptbox/mentions/initial-prompt-draft.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { isRawThreadId } from "@bb/domain"; import type { PromptDraftState } from "@bb/client-core"; import { THREAD_MENTION_RESOLVE_MAX_IDS } from "@bb/server-contract"; @@ -69,6 +69,28 @@ export async function resolveInitialPromptDraft( }; } +interface InitialPromptDraftQueryOptionsArgs { + text: string | null; + navigationPending: boolean; + draft: PromptDraftState; + resolveMentions: typeof sdk.threads.resolveMentions; +} + +export function initialPromptDraftQueryOptions({ + text, + navigationPending, + draft, + resolveMentions, +}: InitialPromptDraftQueryOptionsArgs) { + return queryOptions({ + queryKey: ["initial-prompt-draft", draft], + queryFn: ({ signal }) => + resolveInitialPromptDraft(draft, resolveMentions, signal), + enabled: text !== null && !navigationPending, + staleTime: Infinity, + }); +} + export function useInitialPromptDraft( text: string | null, ): PromptDraftState | undefined { @@ -82,15 +104,12 @@ export function useInitialPromptDraft( }), [resources, text], ); - return useQuery({ - queryKey: ["initial-prompt-draft", draft], - queryFn: ({ signal }) => - resolveInitialPromptDraft( - draft, - (args) => sdk.threads.resolveMentions(args), - signal, - ), - enabled: text !== null && !navigation.isPending, - staleTime: Infinity, - }).data; + return useQuery( + initialPromptDraftQueryOptions({ + text, + navigationPending: navigation.isPending, + draft, + resolveMentions: (args) => sdk.threads.resolveMentions(args), + }), + ).data; } diff --git a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts index 3d4af2cd7bd..ebf6427c77c 100644 --- a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts +++ b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.test.ts @@ -1,5 +1,3 @@ -// @vitest-environment jsdom - import { describe, expect, it } from "vitest"; import { parsePromptMentionClipboardElement, @@ -40,9 +38,15 @@ describe("serializedTextForPromptMentionResource", () => { }); }); +function attributeElement( + attributes: ReturnType, +): Pick { + const values = new Map(Object.entries(attributes)); + return { getAttribute: (name) => values.get(name) ?? null }; +} + describe("parsePromptMentionClipboardElement", () => { it("preserves the typed trigger for a plugin mention copied from a pill", () => { - const element = document.createElement("span"); const resource = { kind: "plugin" as const, pluginId: "github", @@ -50,14 +54,12 @@ describe("parsePromptMentionClipboardElement", () => { itemId: "issue:owner/repo#42", label: "#42 Fix login bug", }; - for (const [key, value] of Object.entries( + const element = attributeElement( promptMentionClipboardDataAttributes({ resource, serializedText: "#42 Fix login bug", }), - )) { - element.setAttribute(key, value); - } + ); expect(parsePromptMentionClipboardElement({ element })).toEqual({ resource, @@ -66,8 +68,7 @@ describe("parsePromptMentionClipboardElement", () => { }); it("rejects plugin mention clipboard text that does not match the resource label", () => { - const element = document.createElement("span"); - for (const [key, value] of Object.entries( + const element = attributeElement( promptMentionClipboardDataAttributes({ resource: { kind: "plugin", @@ -78,9 +79,7 @@ describe("parsePromptMentionClipboardElement", () => { }, serializedText: "#999 Different issue", }), - )) { - element.setAttribute(key, value); - } + ); expect(parsePromptMentionClipboardElement({ element })).toBeNull(); }); diff --git a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts index 62abe9b52a0..43a2165c98e 100644 --- a/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts +++ b/apps/app/src/components/promptbox/mentions/prompt-mention-clipboard.ts @@ -26,7 +26,7 @@ interface PromptMentionClipboardDataAttributesArgs { } interface ParsePromptMentionClipboardElementArgs { - element: Element; + element: Pick; } const promptMentionClipboardResourcePayloadSchema = z.object({ diff --git a/apps/app/src/components/promptbox/prompt-voice-intent.test.ts b/apps/app/src/components/promptbox/prompt-voice-intent.test.ts new file mode 100644 index 00000000000..d605f88757d --- /dev/null +++ b/apps/app/src/components/promptbox/prompt-voice-intent.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PromptDraftState } from "@bb/client-core"; +import type { PromptBoxHandle } from "./PromptBoxInternal"; +import { + createPromptVoiceIntent, + transcribeAfterCompletionTransition, +} from "./prompt-voice-intent"; + +function createPromptBox() { + return { + captureHeightForLayoutChange: vi.fn(), + focusEnd: vi.fn(), + getTextBeforeCursor: vi.fn(), + insertTextAtCursor: vi.fn(), + sendVoiceTranscript: vi.fn(), + playVoiceCompletionTransition: vi.fn(async () => {}), + } satisfies PromptBoxHandle; +} + +describe("transcribeAfterCompletionTransition", () => { + it("waits for the completion transition after transcription resolves", async () => { + let finishTransition: (() => void) | undefined; + const playCompletionTransition = vi.fn( + () => + new Promise((resolve) => { + finishTransition = resolve; + }), + ); + const transcription = transcribeAfterCompletionTransition( + async () => "Transcript", + playCompletionTransition, + undefined, + ); + + await vi.waitFor(() => + expect(playCompletionTransition).toHaveBeenCalledOnce(), + ); + let settled = false; + void transcription.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + finishTransition?.(); + await expect(transcription).resolves.toBe("Transcript"); + }); +}); + +describe("createPromptVoiceIntent", () => { + it("sends only a successful send transcript, never a cancelled or add-to-draft transcript", () => { + const intent = createPromptVoiceIntent(); + const promptBox = createPromptBox(); + const target = { promptBox, draft: undefined }; + + expect(intent.claimStop("recording", true)).toBe(true); + expect(intent.claimStop("recording", true)).toBe(false); + intent.deliverTranscript("first transcript", target); + expect(promptBox.sendVoiceTranscript).toHaveBeenCalledExactlyOnceWith( + "first transcript", + ); + + intent.claimStop("recording", true); + intent.cancel(); + intent.deliverTranscript("cancelled transcript", target); + expect(promptBox.sendVoiceTranscript).toHaveBeenCalledOnce(); + expect(promptBox.insertTextAtCursor).toHaveBeenCalledWith( + "cancelled transcript", + ); + + intent.claimStop("recording", false); + intent.deliverTranscript("draft transcript", target); + expect(promptBox.insertTextAtCursor).toHaveBeenCalledWith( + "draft transcript", + ); + expect(promptBox.sendVoiceTranscript).toHaveBeenCalledOnce(); + }); + + it("does not carry send intent into another recording after transcription fails", () => { + const intent = createPromptVoiceIntent(); + const promptBox = createPromptBox(); + + intent.claimStop("recording", true); + intent.settle("error"); + intent.settle("recording"); + intent.deliverTranscript("later transcript", { + promptBox, + draft: undefined, + }); + + expect(promptBox.sendVoiceTranscript).not.toHaveBeenCalled(); + expect(promptBox.insertTextAtCursor).toHaveBeenCalledExactlyOnceWith( + "later transcript", + ); + }); + + it.each([ + ["send", true], + ["stop", false], + ] as const)( + "preserves %s intent for the originating draft after unmount", + async (_action, send) => { + const intent = createPromptVoiceIntent(); + const promptBox = createPromptBox(); + let draft: PromptDraftState = { + text: "Existing", + mentions: [], + attachments: [], + }; + const submit = vi.fn(async () => { + expect(draft.text).toBe("Existing and later edits late transcript"); + }); + const origin = { + getCurrent: () => draft, + setDraft: (next: PromptDraftState) => { + draft = next; + }, + submit, + }; + + intent.claimStop("recording", send); + intent.setMounted(false); + draft = { ...draft, text: "Existing and later edits" }; + await intent.deliverTranscript("late transcript", { + promptBox, + draft: origin, + }); + + expect(draft.text).toBe("Existing and later edits late transcript"); + expect(promptBox.sendVoiceTranscript).not.toHaveBeenCalled(); + expect(promptBox.insertTextAtCursor).not.toHaveBeenCalled(); + expect(submit).toHaveBeenCalledTimes(send ? 1 : 0); + }, + ); + + it("appends a completed transcript to the originating draft without a prompt box", () => { + const intent = createPromptVoiceIntent(); + let draft: PromptDraftState = { + text: "Existing and later edits", + mentions: [], + attachments: [], + }; + + intent.deliverTranscript("new words", { + promptBox: null, + draft: { + getCurrent: () => draft, + setDraft: (next) => { + draft = next; + }, + }, + }); + + expect(draft).toEqual({ + text: "Existing and later edits new words", + mentions: [], + attachments: [], + }); + }); +}); diff --git a/apps/app/src/components/promptbox/prompt-voice-intent.ts b/apps/app/src/components/promptbox/prompt-voice-intent.ts new file mode 100644 index 00000000000..1e0e7d24603 --- /dev/null +++ b/apps/app/src/components/promptbox/prompt-voice-intent.ts @@ -0,0 +1,87 @@ +import type { PromptDraftState } from "@bb/client-core"; +import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; +import type { PromptBoxHandle, PromptVoiceConfig } from "./PromptBoxInternal"; + +export interface PromptVoiceDraftTarget { + getCurrent: () => PromptDraftState; + setDraft: (draft: PromptDraftState) => void; + submit?: PluginComposerHost["submit"]; +} + +interface PromptVoiceTranscriptTarget { + promptBox: PromptBoxHandle | null; + draft: PromptVoiceDraftTarget | undefined; +} + +export interface PromptVoiceIntent { + setMounted: (mounted: boolean) => void; + claimStop: (state: PromptVoiceConfig["state"], send: boolean) => boolean; + cancel: () => void; + settle: (state: PromptVoiceConfig["state"]) => void; + deliverTranscript: ( + text: string, + target: PromptVoiceTranscriptTarget, + ) => Promise | undefined; +} + +export function createPromptVoiceIntent(): PromptVoiceIntent { + let sendPending = false; + let stopped = false; + let mounted = true; + return { + setMounted: (next) => { + mounted = next; + }, + claimStop: (state, send) => { + if (state !== "recording" || stopped) return false; + stopped = true; + sendPending = send; + return true; + }, + cancel: () => { + sendPending = false; + stopped = false; + }, + settle: (state) => { + if (state !== "recording" && state !== "transcribing") { + stopped = false; + sendPending = false; + } + }, + deliverTranscript: (text, { promptBox, draft }) => { + const send = sendPending; + sendPending = false; + stopped = false; + if (mounted && promptBox) { + if (send) { + promptBox.sendVoiceTranscript(text); + } else { + promptBox.insertTextAtCursor(text); + } + return; + } + if (!draft) return; + const current = draft.getCurrent(); + const separator = + current.text.length > 0 && !/\s$/.test(current.text) ? " " : ""; + draft.setDraft({ + ...current, + text: `${current.text}${separator}${text}`, + }); + if (send) return draft.submit?.({ experimental_data: null }, undefined); + }, + }; +} + +export async function transcribeAfterCompletionTransition( + transcribe: () => Promise, + playCompletionTransition: () => Promise | undefined, + signal: AbortSignal | undefined, +): Promise { + const text = await transcribe(); + await playCompletionTransition(); + if (signal?.aborted) { + throw new DOMException("Voice transcription was cancelled", "AbortError"); + } + return text; +} diff --git a/apps/app/src/components/promptbox/usePendingAttachmentUploads.test.ts b/apps/app/src/components/promptbox/usePendingAttachmentUploads.test.ts new file mode 100644 index 00000000000..8bb0246ef17 --- /dev/null +++ b/apps/app/src/components/promptbox/usePendingAttachmentUploads.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createPendingAttachmentUploads } from "./usePendingAttachmentUploads"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("gives same-named previews distinct ids without the secure-context randomUUID API", () => { + vi.stubGlobal("crypto", { + getRandomValues: crypto.getRandomValues.bind(crypto), + }); + const file = new File(["image"], "same-name.png", { type: "image/png" }); + + const uploads = createPendingAttachmentUploads([file, file, file]); + + expect(new Set(uploads.map((upload) => upload.id)).size).toBe(3); +}); diff --git a/apps/app/src/components/promptbox/usePendingAttachmentUploads.ts b/apps/app/src/components/promptbox/usePendingAttachmentUploads.ts index 8472b79e62b..a8ff3553952 100644 --- a/apps/app/src/components/promptbox/usePendingAttachmentUploads.ts +++ b/apps/app/src/components/promptbox/usePendingAttachmentUploads.ts @@ -8,6 +8,12 @@ export interface PendingAttachmentUpload { const EMPTY_UPLOADS: readonly PendingAttachmentUpload[] = []; +export function createPendingAttachmentUploads( + files: readonly File[], +): PendingAttachmentUpload[] { + return files.map((file) => ({ id: nanoid(), file })); +} + export function usePendingAttachmentUploads(targetKey: string | null) { const [pending, setPending] = useState<{ targetKey: string | null; @@ -15,7 +21,7 @@ export function usePendingAttachmentUploads(targetKey: string | null) { }>({ targetKey: null, uploads: [] }); const startUploads = useCallback((files: File[]) => { - const uploads = files.map((file) => ({ id: nanoid(), file })); + const uploads = createPendingAttachmentUploads(files); setPending((current) => ({ targetKey, uploads: [...(current.targetKey === targetKey ? current.uploads : []), ...uploads], diff --git a/apps/app/src/components/promptbox/usePromptVoice.test.tsx b/apps/app/src/components/promptbox/usePromptVoice.test.tsx deleted file mode 100644 index 4abfe1a3719..00000000000 --- a/apps/app/src/components/promptbox/usePromptVoice.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { transcribeVoiceInput } from "@/lib/api"; -import { useVoiceInput } from "@/hooks/useVoiceInput"; -import type { PromptBoxHandle } from "./PromptBoxInternal"; -import { usePromptVoice } from "./usePromptVoice"; -import type { PromptDraftState } from "@bb/client-core"; - -vi.mock("@/lib/api", () => ({ - transcribeVoiceInput: vi.fn(), -})); - -vi.mock("@/hooks/useVoiceInput", () => ({ - useVoiceInput: vi.fn(), -})); - -const voiceInput = { - state: "transcribing" as const, - microphoneWarning: null, - isSupported: true, - unsupportedReason: null, - stream: null, - start: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), -}; - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("usePromptVoice", () => { - it("waits for the completion transition after transcription resolves", async () => { - vi.mocked(useVoiceInput).mockReturnValue({ - ...voiceInput, - isRecording: false, - isProcessing: true, - isListening: false, - }); - vi.mocked(transcribeVoiceInput).mockResolvedValue({ text: "Transcript" }); - - let finishTransition: (() => void) | undefined; - const playVoiceCompletionTransition = vi.fn( - () => - new Promise((resolve) => { - finishTransition = resolve; - }), - ); - const insertTextAtCursor = vi.fn(); - const promptBoxRef = { - current: { - captureHeightForLayoutChange: vi.fn(), - focusEnd: vi.fn(), - getTextBeforeCursor: vi.fn(), - insertTextAtCursor, - sendVoiceTranscript: vi.fn(), - playVoiceCompletionTransition, - } satisfies PromptBoxHandle, - }; - - renderHook(() => usePromptVoice(promptBoxRef)); - const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; - const transcription = options?.onTranscribe({ - file: new File([], "recording.webm", { type: "audio/webm" }), - }); - - await act(async () => { - await Promise.resolve(); - }); - expect(playVoiceCompletionTransition).toHaveBeenCalledOnce(); - - let settled = false; - void transcription?.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - - finishTransition?.(); - await expect(transcription).resolves.toBe("Transcript"); - expect(insertTextAtCursor).not.toHaveBeenCalled(); - }); - - it("sends only a successful send transcript, never a cancelled or add-to-draft transcript", () => { - vi.mocked(useVoiceInput).mockReturnValue({ - ...voiceInput, - state: "recording", - isRecording: true, - isProcessing: false, - isListening: true, - }); - const insertTextAtCursor = vi.fn(); - const sendVoiceTranscript = vi.fn(); - const promptBoxRef = { - current: { - captureHeightForLayoutChange: vi.fn(), - focusEnd: vi.fn(), - getTextBeforeCursor: vi.fn(), - insertTextAtCursor, - sendVoiceTranscript, - playVoiceCompletionTransition: vi.fn(), - } satisfies PromptBoxHandle, - }; - const { result } = renderHook(() => usePromptVoice(promptBoxRef)); - const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; - act(() => { - result.current.send(); - result.current.send(); - }); - expect(voiceInput.stop).toHaveBeenCalledOnce(); - act(() => options?.onTranscript("first transcript")); - expect(sendVoiceTranscript).toHaveBeenCalledExactlyOnceWith( - "first transcript", - ); - act(() => { - result.current.send(); - result.current.cancel(); - options?.onTranscript("cancelled transcript"); - }); - expect(sendVoiceTranscript).toHaveBeenCalledOnce(); - expect(insertTextAtCursor).toHaveBeenCalledWith("cancelled transcript"); - act(() => { - result.current.stop(); - options?.onTranscript("draft transcript"); - }); - expect(insertTextAtCursor).toHaveBeenCalledWith("draft transcript"); - expect(sendVoiceTranscript).toHaveBeenCalledOnce(); - }); - - it("does not carry send intent into another recording after transcription fails", () => { - let state: "recording" | "error" = "recording"; - vi.mocked(useVoiceInput).mockImplementation(() => ({ - ...voiceInput, - state, - isRecording: state === "recording", - isProcessing: false, - isListening: state === "recording", - })); - const insertTextAtCursor = vi.fn(); - const sendVoiceTranscript = vi.fn(); - const promptBoxRef = { - current: { - captureHeightForLayoutChange: vi.fn(), - focusEnd: vi.fn(), - getTextBeforeCursor: vi.fn(), - insertTextAtCursor, - sendVoiceTranscript, - playVoiceCompletionTransition: vi.fn(), - } satisfies PromptBoxHandle, - }; - const { result, rerender } = renderHook(() => usePromptVoice(promptBoxRef)); - const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; - act(() => result.current.send()); - state = "error"; - rerender(); - state = "recording"; - rerender(); - act(() => options?.onTranscript("later transcript")); - expect(sendVoiceTranscript).not.toHaveBeenCalled(); - expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith( - "later transcript", - ); - }); - - it.each(["send", "stop"] as const)( - "preserves %s intent for the originating draft after navigating away", - async (action) => { - vi.mocked(useVoiceInput).mockReturnValue({ - ...voiceInput, - state: "recording", - isRecording: true, - isProcessing: false, - isListening: true, - }); - const sendVoiceTranscript = vi.fn(); - const promptBoxRef = { - current: { - captureHeightForLayoutChange: vi.fn(), - focusEnd: vi.fn(), - getTextBeforeCursor: vi.fn(), - insertTextAtCursor: vi.fn(), - sendVoiceTranscript, - playVoiceCompletionTransition: vi.fn(), - } satisfies PromptBoxHandle, - }; - let draft: PromptDraftState = { - text: "Existing", - mentions: [], - attachments: [], - }; - const submit = vi.fn(async () => { - expect(draft.text).toBe("Existing and later edits late transcript"); - }); - const origin = { - getCurrent: () => draft, - setDraft: (next: PromptDraftState) => { - draft = next; - }, - submit, - }; - const { result, unmount } = renderHook(() => - usePromptVoice(promptBoxRef, origin), - ); - const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; - act(() => result.current[action]()); - unmount(); - draft = { ...draft, text: "Existing and later edits" }; - await options?.onTranscript("late transcript"); - expect(draft.text).toBe("Existing and later edits late transcript"); - expect(sendVoiceTranscript).not.toHaveBeenCalled(); - expect(promptBoxRef.current.insertTextAtCursor).not.toHaveBeenCalled(); - expect(submit).toHaveBeenCalledTimes(action === "send" ? 1 : 0); - }, - ); - - it("appends a completed transcript to the originating draft after unmount", () => { - vi.mocked(useVoiceInput).mockReturnValue({ - ...voiceInput, - isRecording: false, - isProcessing: true, - isListening: false, - }); - const promptBoxRef = { current: null }; - let draft: PromptDraftState = { - text: "Existing", - mentions: [], - attachments: [], - }; - const getCurrent = vi.fn(() => draft); - const setDraft = vi.fn((next: PromptDraftState) => { - draft = next; - }); - renderHook(() => usePromptVoice(promptBoxRef, { getCurrent, setDraft })); - const options = vi.mocked(useVoiceInput).mock.calls[0]?.[0]; - draft = { ...draft, text: "Existing and later edits" }; - options?.onTranscript("new words"); - expect(draft).toEqual({ - text: "Existing and later edits new words", - mentions: [], - attachments: [], - }); - }); -}); diff --git a/apps/app/src/components/promptbox/usePromptVoice.ts b/apps/app/src/components/promptbox/usePromptVoice.ts index 02679b7cc91..e7d720026bf 100644 --- a/apps/app/src/components/promptbox/usePromptVoice.ts +++ b/apps/app/src/components/promptbox/usePromptVoice.ts @@ -1,9 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, type RefObject } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type RefObject, +} from "react"; import { useVoiceInput } from "@/hooks/useVoiceInput"; import { transcribeVoiceInput } from "@/lib/api"; -import type { PromptDraftState } from "@bb/client-core"; -import type { PluginComposerHost } from "@/components/plugin/plugin-composer-host"; import type { PromptBoxHandle, PromptVoiceConfig } from "./PromptBoxInternal"; +import { + createPromptVoiceIntent, + transcribeAfterCompletionTransition, + type PromptVoiceDraftTarget, +} from "./prompt-voice-intent"; async function requestVoiceTranscription({ file, @@ -18,52 +27,25 @@ async function requestVoiceTranscription({ return transcription.text; } -function createVoiceAbortError(): DOMException { - return new DOMException("Voice transcription was cancelled", "AbortError"); -} - export function usePromptVoice( promptBoxRef: RefObject, - draft?: { - getCurrent: () => PromptDraftState; - setDraft: (draft: PromptDraftState) => void; - submit?: PluginComposerHost["submit"]; - }, + draft?: PromptVoiceDraftTarget, ): PromptVoiceConfig { - const sendPendingRef = useRef(false); - const stoppedRef = useRef(false); - const mountedRef = useRef(true); + const [intent] = useState(createPromptVoiceIntent); useEffect(() => { - mountedRef.current = true; + intent.setMounted(true); return () => { - mountedRef.current = false; + intent.setMounted(false); }; - }, []); + }, [intent]); const onTranscript = useCallback( - (text: string) => { - const send = sendPendingRef.current; - sendPendingRef.current = false; - stoppedRef.current = false; - if (mountedRef.current && promptBoxRef.current) { - if (send) { - promptBoxRef.current.sendVoiceTranscript(text); - } else { - promptBoxRef.current.insertTextAtCursor(text); - } - return; - } - if (!draft) return; - const current = draft.getCurrent(); - const separator = - current.text.length > 0 && !/\s$/.test(current.text) ? " " : ""; - draft.setDraft({ - ...current, - text: `${current.text}${separator}${text}`, - }); - if (send) return draft.submit?.({ experimental_data: null }, undefined); - }, - [draft, promptBoxRef], + (text: string) => + intent.deliverTranscript(text, { + promptBox: promptBoxRef.current, + draft, + }), + [draft, intent, promptBoxRef], ); const getPromptContext = useCallback( @@ -71,50 +53,35 @@ export function usePromptVoice( [promptBoxRef], ); - const transcribeAfterCompletionTransition = useCallback( - async (args: Parameters[0]) => { - const text = await requestVoiceTranscription(args); - await promptBoxRef.current?.playVoiceCompletionTransition(); - if (args.signal?.aborted) { - throw createVoiceAbortError(); - } - return text; - }, + const onTranscribe = useCallback( + (args: Parameters[0]) => + transcribeAfterCompletionTransition( + () => requestVoiceTranscription(args), + () => promptBoxRef.current?.playVoiceCompletionTransition(), + args.signal, + ), [promptBoxRef], ); const voiceInput = useVoiceInput({ onTranscript, - onTranscribe: transcribeAfterCompletionTransition, + onTranscribe, getPromptContext, }); const stop = useCallback(() => { - if (voiceInput.state !== "recording" || stoppedRef.current) return; - stoppedRef.current = true; - sendPendingRef.current = false; - voiceInput.stop(); - }, [voiceInput]); + if (intent.claimStop(voiceInput.state, false)) voiceInput.stop(); + }, [intent, voiceInput]); const send = useCallback(() => { - if (voiceInput.state !== "recording" || stoppedRef.current) return; - stoppedRef.current = true; - sendPendingRef.current = true; - voiceInput.stop(); - }, [voiceInput]); + if (intent.claimStop(voiceInput.state, true)) voiceInput.stop(); + }, [intent, voiceInput]); const cancel = useCallback(() => { - sendPendingRef.current = false; - stoppedRef.current = false; + intent.cancel(); voiceInput.cancel(); - }, [voiceInput]); + }, [intent, voiceInput]); useEffect(() => { - if ( - voiceInput.state !== "recording" && - voiceInput.state !== "transcribing" - ) { - stoppedRef.current = false; - sendPendingRef.current = false; - } - }, [voiceInput.state]); + intent.settle(voiceInput.state); + }, [intent, voiceInput.state]); return useMemo( () => ({ diff --git a/apps/app/src/components/provider-cli/provider-cli-install.test.tsx b/apps/app/src/components/provider-cli/provider-cli-install.test.ts similarity index 67% rename from apps/app/src/components/provider-cli/provider-cli-install.test.tsx rename to apps/app/src/components/provider-cli/provider-cli-install.test.ts index 2fb6365c4d0..f90ac21dd7e 100644 --- a/apps/app/src/components/provider-cli/provider-cli-install.test.tsx +++ b/apps/app/src/components/provider-cli/provider-cli-install.test.ts @@ -1,8 +1,4 @@ -// @vitest-environment jsdom - -import type { ReactNode } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient } from "@tanstack/react-query"; import type { ProviderCliInstallEvent } from "@bb/host-daemon-contract"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BbHttpError } from "@bb/sdk/browser"; @@ -13,15 +9,15 @@ import { hostProviderCliStatusQueryKey, } from "@/hooks/queries/query-keys"; import type { ProviderCliActionableIssue } from "./provider-cli-install"; +import { buildProviderCliIssue } from "./provider-cli-install"; import { - buildProviderCliIssue, - useProviderCliInstallRunner, -} from "./provider-cli-install"; -import { + getProviderCliInstallSnapshot, PROVIDER_CLI_FAILURE_LOG_MAX_BYTES, PROVIDER_CLI_FAILURE_MAX_ENTRIES, registerProviderCliInstallQueryClient, resetProviderCliInstallStoreForTests, + startProviderCliInstall, + subscribeProviderCliInstalls, } from "./provider-cli-install-store"; interface DeferredInstall { @@ -61,16 +57,6 @@ const appToastMock = vi.mocked(appToast); let pendingInstalls: DeferredInstall[] = []; let queryClient: QueryClient; -function Wrapper({ children }: { children: ReactNode }) { - return ( - {children} - ); -} - -function renderRunner() { - return renderHook(() => useProviderCliInstallRunner(), { wrapper: Wrapper }); -} - function issueForProvider( provider: "codex" | "claude-code", ): ProviderCliActionableIssue { @@ -106,6 +92,10 @@ function issueForProvider( }; } +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + function completeInstall( install: DeferredInstall, event: ProviderCliInstallEvent, @@ -137,7 +127,6 @@ beforeEach(() => { }); afterEach(() => { - cleanup(); vi.clearAllMocks(); }); @@ -177,16 +166,13 @@ describe("buildProviderCliIssue", () => { }); }); -describe("useProviderCliInstallRunner", () => { +describe("provider CLI install store", () => { it("queues a second provider CLI setup behind the active one", async () => { const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); - const { result } = renderRunner(); - act(() => { - result.current.startInstall({ - hostId: "host_1", - issue: issueForProvider("codex"), - }); + startProviderCliInstall({ + hostId: "host_1", + issue: issueForProvider("codex"), }); expect(installHostProviderCliMock).toHaveBeenCalledTimes(1); @@ -197,29 +183,28 @@ describe("useProviderCliInstallRunner", () => { }), ); - act(() => { - result.current.startInstall({ - hostId: "host_1", - issue: issueForProvider("claude-code"), - }); + startProviderCliInstall({ + hostId: "host_1", + issue: issueForProvider("claude-code"), }); expect(installHostProviderCliMock).toHaveBeenCalledTimes(1); expect(appToastMock.message).not.toHaveBeenCalled(); expect(appToastMock.loading).not.toHaveBeenCalled(); - expect(result.current.queuedJobKeys.has("host_1:claude-code")).toBe(true); - - await act(async () => { - completeInstall(installAt(0), { - type: "completed", - provider: "codex", - success: true, - exitCode: 0, - signal: null, - }); + expect( + getProviderCliInstallSnapshot().queuedJobKeys.has("host_1:claude-code"), + ).toBe(true); + + completeInstall(installAt(0), { + type: "completed", + provider: "codex", + success: true, + exitCode: 0, + signal: null, }); + await settle(); - await waitFor(() => { + await vi.waitFor(() => { expect(installHostProviderCliMock).toHaveBeenCalledTimes(2); }); expect(installHostProviderCliMock).toHaveBeenLastCalledWith( @@ -228,17 +213,18 @@ describe("useProviderCliInstallRunner", () => { actionKind: "update", }), ); - expect(result.current.queuedJobKeys.has("host_1:claude-code")).toBe(false); + expect( + getProviderCliInstallSnapshot().queuedJobKeys.has("host_1:claude-code"), + ).toBe(false); - await act(async () => { - completeInstall(installAt(1), { - type: "completed", - provider: "claude-code", - success: true, - exitCode: 0, - signal: null, - }); + completeInstall(installAt(1), { + type: "completed", + provider: "claude-code", + success: true, + exitCode: 0, + signal: null, }); + await settle(); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: hostProviderCliStatusQueryKey("host_1"), @@ -250,35 +236,32 @@ describe("useProviderCliInstallRunner", () => { expect(appToastMock.success).not.toHaveBeenCalled(); }); - it("keeps draining the queue after every consumer unmounts", async () => { + it("keeps draining the queue after every subscriber leaves", async () => { const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); - const { result, unmount } = renderRunner(); - - act(() => { - result.current.startInstall({ - hostId: "host_1", - issue: issueForProvider("codex"), - }); - result.current.startInstall({ - hostId: "host_1", - issue: issueForProvider("claude-code"), - }); + const unsubscribe = subscribeProviderCliInstalls(() => {}); + + startProviderCliInstall({ + hostId: "host_1", + issue: issueForProvider("codex"), + }); + startProviderCliInstall({ + hostId: "host_1", + issue: issueForProvider("claude-code"), }); expect(installHostProviderCliMock).toHaveBeenCalledTimes(1); - unmount(); + unsubscribe(); - await act(async () => { - completeInstall(installAt(0), { - type: "completed", - provider: "codex", - success: true, - exitCode: 0, - signal: null, - }); + completeInstall(installAt(0), { + type: "completed", + provider: "codex", + success: true, + exitCode: 0, + signal: null, }); + await settle(); - await waitFor(() => { + await vi.waitFor(() => { expect(installHostProviderCliMock).toHaveBeenCalledTimes(2); }); expect(installHostProviderCliMock).toHaveBeenLastCalledWith( @@ -288,43 +271,40 @@ describe("useProviderCliInstallRunner", () => { queryKey: hostProviderCliStatusQueryKey("host_1"), }); - const remounted = renderRunner(); - expect(remounted.result.current.runningJobKey).toBe("host_1:claude-code"); + expect(getProviderCliInstallSnapshot().runningJobKey).toBe( + "host_1:claude-code", + ); }); it("keeps a failed install and its stderr available for a retry", async () => { - const { result } = renderRunner(); const issue = issueForProvider("codex"); - act(() => { - result.current.startInstall({ - hostId: "host_1", - issue, - }); + startProviderCliInstall({ + hostId: "host_1", + issue, }); - await act(async () => { - installAt(0).resolve([ - { - type: "started", - provider: "codex", - command: "codex update", - }, - { - type: "output", - provider: "codex", - stream: "stderr", - text: "permission denied\n", - }, - { - type: "completed", - provider: "codex", - success: false, - exitCode: 1, - signal: null, - }, - ]); - }); + installAt(0).resolve([ + { + type: "started", + provider: "codex", + command: "codex update", + }, + { + type: "output", + provider: "codex", + stream: "stderr", + text: "permission denied\n", + }, + { + type: "completed", + provider: "codex", + success: false, + exitCode: 1, + signal: null, + }, + ]); + await settle(); expect(appToastMock.error).toHaveBeenCalledWith( "Codex update failed", @@ -333,7 +313,9 @@ describe("useProviderCliInstallRunner", () => { action: expect.objectContaining({ label: "View log" }), }), ); - expect(result.current.failuresByJobKey.get("host_1:codex")).toMatchObject({ + expect( + getProviderCliInstallSnapshot().failuresByJobKey.get("host_1:codex"), + ).toMatchObject({ issueFingerprint: issue.fingerprint, kind: "command", logDialogState: { @@ -342,43 +324,42 @@ describe("useProviderCliInstallRunner", () => { }, }); - act(() => { - result.current.startInstall({ hostId: "host_1", issue }); - }); - expect(result.current.failuresByJobKey.has("host_1:codex")).toBe(false); + startProviderCliInstall({ hostId: "host_1", issue }); + expect( + getProviderCliInstallSnapshot().failuresByJobKey.has("host_1:codex"), + ).toBe(false); }); it("summarizes a dropped connection but keeps a server rejection's reason", async () => { - const { result } = renderRunner(); const codex = issueForProvider("codex"); const claude = issueForProvider("claude-code"); - act(() => { - result.current.startInstall({ hostId: "host_1", issue: codex }); - result.current.startInstall({ hostId: "host_1", issue: claude }); - }); - - await act(async () => { - installAt(0).reject(new TypeError("Failed to fetch")); - }); - await waitFor(() => expect(pendingInstalls).toHaveLength(2)); - await act(async () => { - installAt(1).reject( - new BbHttpError({ - status: 409, - code: "provider_bridge_unavailable", - message: "Provider bridge unavailable", - body: null, - }), - ); - }); + startProviderCliInstall({ hostId: "host_1", issue: codex }); + startProviderCliInstall({ hostId: "host_1", issue: claude }); + + installAt(0).reject(new TypeError("Failed to fetch")); + await settle(); + await vi.waitFor(() => expect(pendingInstalls).toHaveLength(2)); + installAt(1).reject( + new BbHttpError({ + status: 409, + code: "provider_bridge_unavailable", + message: "Provider bridge unavailable", + body: null, + }), + ); + await settle(); - expect(result.current.failuresByJobKey.get("host_1:codex")).toMatchObject({ + expect( + getProviderCliInstallSnapshot().failuresByJobKey.get("host_1:codex"), + ).toMatchObject({ kind: "interrupted", logDialogState: { message: "Connection lost during update" }, }); expect( - result.current.failuresByJobKey.get("host_1:claude-code"), + getProviderCliInstallSnapshot().failuresByJobKey.get( + "host_1:claude-code", + ), ).toMatchObject({ kind: "command", logDialogState: { message: "Provider bridge unavailable" }, @@ -390,47 +371,45 @@ describe("useProviderCliInstallRunner", () => { }); it("bounds retained failures by entry count and log bytes", async () => { - const { result } = renderRunner(); const issue = issueForProvider("codex"); for (let index = 0; index <= PROVIDER_CLI_FAILURE_MAX_ENTRIES; index += 1) { const hostId = `host_${index}`; - act(() => { - result.current.startInstall({ hostId, issue }); - }); + startProviderCliInstall({ hostId, issue }); const output = index === PROVIDER_CLI_FAILURE_MAX_ENTRIES ? `first line\n${"x".repeat(PROVIDER_CLI_FAILURE_LOG_MAX_BYTES * 2)}\nlast line\n` : "failed\n"; - await act(async () => { - installAt(index).resolve([ - { - type: "started", - provider: "codex", - command: "codex update", - }, - { - type: "output", - provider: "codex", - stream: "stderr", - text: output, - }, - { - type: "completed", - provider: "codex", - success: false, - exitCode: 1, - signal: null, - }, - ]); - }); + installAt(index).resolve([ + { + type: "started", + provider: "codex", + command: "codex update", + }, + { + type: "output", + provider: "codex", + stream: "stderr", + text: output, + }, + { + type: "completed", + provider: "codex", + success: false, + exitCode: 1, + signal: null, + }, + ]); + await settle(); } - expect(result.current.failuresByJobKey.size).toBe( + expect(getProviderCliInstallSnapshot().failuresByJobKey.size).toBe( PROVIDER_CLI_FAILURE_MAX_ENTRIES, ); - expect(result.current.failuresByJobKey.has("host_0:codex")).toBe(false); - const newestFailure = result.current.failuresByJobKey.get( + expect( + getProviderCliInstallSnapshot().failuresByJobKey.has("host_0:codex"), + ).toBe(false); + const newestFailure = getProviderCliInstallSnapshot().failuresByJobKey.get( `host_${PROVIDER_CLI_FAILURE_MAX_ENTRIES}:codex`, ); if (newestFailure === undefined) { diff --git a/apps/app/src/components/secondary-panel/TerminalHostSelector.test.ts b/apps/app/src/components/secondary-panel/TerminalHostSelector.test.ts new file mode 100644 index 00000000000..dff5411127d --- /dev/null +++ b/apps/app/src/components/secondary-panel/TerminalHostSelector.test.ts @@ -0,0 +1,55 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Host } from "@bb/domain"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { describe, expect, it } from "vitest"; +import { + resolveTerminalHost, + TerminalHostSelector, +} from "./TerminalHostSelector"; + +const studio = makeHost({ + id: "host-studio", + name: "Studio", +}); +const laptop = makeHost({ + ...studio, + id: "host-laptop", + name: "Laptop", +}); + +function renderSelector(hosts: readonly Host[]) { + return renderToStaticMarkup( + createElement(TerminalHostSelector, { + disabled: false, + hosts, + isLoading: false, + onChange: () => {}, + selectedHostId: studio.id, + }), + ); +} + +describe("TerminalHostSelector", () => { + it("shows a single machine as a quiet value instead of a picker", () => { + const markup = renderSelector([studio]); + + expect(markup).toContain("Studio"); + expect(markup).not.toContain(" { + it("falls back from an unavailable preference to a connected machine", () => { + const offlineLaptop = { ...laptop, status: "disconnected" as const }; + + expect( + resolveTerminalHost({ + hosts: [offlineLaptop, studio], + preferredHostId: offlineLaptop.id, + primaryHostId: offlineLaptop.id, + }), + ).toBe(studio); + }); +}); diff --git a/apps/app/src/components/secondary-panel/TerminalHostSelector.test.tsx b/apps/app/src/components/secondary-panel/TerminalHostSelector.test.tsx deleted file mode 100644 index dbfeee6b660..00000000000 --- a/apps/app/src/components/secondary-panel/TerminalHostSelector.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { makeHost } from "@bb/test-helpers/domain-fixtures"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveTerminalHost, - TerminalHostSelector, -} from "./TerminalHostSelector"; - -const studio = makeHost({ - id: "host-studio", - name: "Studio", -}); -const laptop = makeHost({ - ...studio, - id: "host-laptop", - name: "Laptop", -}); - -afterEach(cleanup); - -describe("TerminalHostSelector", () => { - it("shows a single machine as a quiet value", () => { - render( - , - ); - - expect(screen.getByText("Studio")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); - }); - - it("lets a user choose among several machines", () => { - const onChange = vi.fn(); - render( - , - ); - - fireEvent.pointerDown(screen.getByRole("button", { name: "Machine" }), { - button: 0, - }); - fireEvent.click(screen.getByRole("menuitem", { name: /Laptop/u })); - - expect(onChange).toHaveBeenCalledWith(laptop.id); - }); -}); - -describe("resolveTerminalHost", () => { - it("falls back from an unavailable preference to a connected machine", () => { - const offlineLaptop = { ...laptop, status: "disconnected" as const }; - - expect( - resolveTerminalHost({ - hosts: [offlineLaptop, studio], - preferredHostId: offlineLaptop.id, - primaryHostId: offlineLaptop.id, - }), - ).toBe(studio); - }); -}); diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.dom.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.dom.test.tsx new file mode 100644 index 00000000000..22fa7875bb9 --- /dev/null +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.dom.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { focusWithKeyboard } from "@/test/keyboard-focus"; +import { + hostsQueryKey, + systemMachineProvidersQueryKey, +} from "@/hooks/queries/query-keys"; +import { makeEnvironment, makeThread } from "@bb/test-helpers/domain-fixtures"; +import { EnvironmentRow, ThreadMetadataCard } from "./ThreadMetadataContent"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("ThreadMetadataCard", () => { + it("shows its scrollbar only during active scrolling", () => { + vi.useFakeTimers(); + const { container } = render( + +
Thread information
+
, + ); + const scrollArea = container.querySelector("dl"); + if (!(scrollArea instanceof HTMLElement)) { + throw new Error("missing info scroll area"); + } + + expect(scrollArea.classList).toContain("transient-scrollbar"); + expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); + + fireEvent.scroll(scrollArea); + expect(scrollArea.dataset.scrollbarScrolling).toBe("true"); + + act(() => vi.advanceTimersByTime(599)); + expect(scrollArea.dataset.scrollbarScrolling).toBe("true"); + + act(() => vi.advanceTimersByTime(1)); + expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); + }); +}); + +describe("EnvironmentRow", () => { + it("explains the create-thread action in a tooltip", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(hostsQueryKey(), []); + queryClient.setQueryData(systemMachineProvidersQueryKey(), []); + render( + + + + + + + , + ); + + focusWithKeyboard( + screen.getByRole("button", { + name: "New thread in environment", + }), + ); + + expect((await screen.findByRole("tooltip")).textContent).toBe( + "New thread in environment", + ); + }); +}); diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index 17b888ba2ea..1434b5fce05 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -1,12 +1,3 @@ -// @vitest-environment jsdom - -import { - act, - cleanup, - fireEvent, - render, - screen, -} from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; import type { Environment, Host, Thread } from "@bb/domain"; @@ -23,8 +14,7 @@ import { import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { focusWithKeyboard } from "@/test/keyboard-focus"; +import { describe, expect, it } from "vitest"; import { makeEnvironment, makeHost, @@ -36,7 +26,6 @@ import { EnvironmentRow, formatBranchComparison, GitStatusRow, - ThreadMetadataCard, } from "./ThreadMetadataContent"; const localHost = { locality: "local", identity: null } as const; @@ -167,38 +156,6 @@ function renderEnvironmentRow( ); } -afterEach(() => { - cleanup(); - vi.useRealTimers(); -}); - -describe("ThreadMetadataCard", () => { - it("shows its scrollbar only during active scrolling", () => { - vi.useFakeTimers(); - const { container } = render( - -
Thread information
-
, - ); - const scrollArea = container.querySelector("dl"); - if (!(scrollArea instanceof HTMLElement)) { - throw new Error("missing info scroll area"); - } - - expect(scrollArea.classList).toContain("transient-scrollbar"); - expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); - - fireEvent.scroll(scrollArea); - expect(scrollArea.dataset.scrollbarScrolling).toBe("true"); - - act(() => vi.advanceTimersByTime(599)); - expect(scrollArea.dataset.scrollbarScrolling).toBe("true"); - - act(() => vi.advanceTimersByTime(1)); - expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false); - }); -}); - describe("EnvironmentRow", () => { it("shows an unregistered provider id as not installed", () => { const markup = renderEnvironmentRow( @@ -264,32 +221,6 @@ describe("EnvironmentRow", () => { expect(markup).not.toContain('aria-label="New thread in environment"'); }); - it("explains the create-thread action in a tooltip", async () => { - render( - withQueryClient( - - - - - , - ), - ); - - focusWithKeyboard( - screen.getByRole("button", { - name: "New thread in environment", - }), - ); - - expect((await screen.findByRole("tooltip")).textContent).toBe( - "New thread in environment", - ); - }); - it("hides the create-thread action while an environment is provisioning", () => { const markup = renderEnvironmentRow( makeEnvironment({ diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx index 0137aed83b6..318e0f47765 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx @@ -1,7 +1,6 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; import { HostFilePreviewTabContent, HostScopedFilePreviewTabContent, @@ -75,10 +74,16 @@ vi.mock("@/hooks/queries/host-file-preview-query", () => ({ ), })); -afterEach(cleanup); - -function imageSrc(name: string): string | null { - return screen.getByRole("img", { name }).getAttribute("src"); +function renderImageSources(element: ReactElement): Map { + const sources = new Map(); + for (const [tag] of renderToStaticMarkup(element).matchAll( + /]*>/gu, + )) { + const alt = /\balt="([^"]*)"/u.exec(tag)?.[1]; + const src = /\bsrc="([^"]*)"/u.exec(tag)?.[1]; + if (alt !== undefined && src !== undefined) sources.set(alt, src); + } + return sources; } it("renders project HTML through the selected host's file route", () => { @@ -111,7 +116,7 @@ it("renders project HTML through the selected host's file route", () => { describe("secondary-panel Markdown image routing", () => { it("routes workspace images when the preview caller supplies no routing", () => { - render( + const imageSrc = renderImageSources( { />, ); - expect(imageSrc("absolute")).toBe( + expect(imageSrc.get("absolute")).toBe( "/api/v1/threads/thr_preview/host-files/workspace/generated.png", ); - expect(imageSrc("relative")).toBe( + expect(imageSrc.get("relative")).toBe( "/api/v1/environments/env_preview/files/docs/images/chart.png", ); - expect(imageSrc("escape")).toBe("../../outside.png"); + expect(imageSrc.get("escape")).toBe("../../outside.png"); }); it("routes project images through the selected project source", () => { - render( + const imageSrc = renderImageSources( { />, ); - expect(imageSrc("absolute")).toBe( + expect(imageSrc.get("absolute")).toBe( "/api/v1/projects/proj_preview/hosts/host_preview/files/generated.png", ); - expect(imageSrc("relative")).toBe( + expect(imageSrc.get("relative")).toBe( "/api/v1/projects/proj_preview/hosts/host_preview/files/docs/images/chart.png", ); - expect(imageSrc("escape")).toBe("../../outside.png"); + expect(imageSrc.get("escape")).toBe("../../outside.png"); }); it("routes thread host-file images through the host content endpoint", () => { - render( + const imageSrc = renderImageSources( { />, ); - expect(imageSrc("absolute")).toBe( + expect(imageSrc.get("absolute")).toBe( "/api/v1/threads/thr_preview/host-files/workspace/generated.png", ); - expect(imageSrc("relative")).toBe( + expect(imageSrc.get("relative")).toBe( "/api/v1/threads/thr_preview/host-files/workspace/docs/images/chart.png", ); - expect(imageSrc("escape")).toBe("../../outside.png"); + expect(imageSrc.get("escape")).toBe("../../outside.png"); }); it("confines host-scoped relative images to the previewed folder", () => { - render( + const imageSrc = renderImageSources( { />, ); - expect(imageSrc("absolute")).toBe("/workspace/generated.png"); - expect(imageSrc("relative")).toBe( + expect(imageSrc.get("absolute")).toBe("/workspace/generated.png"); + expect(imageSrc.get("relative")).toBe( "/api/v1/hosts/host_preview/files/workspace/docs/images/chart.png", ); - expect(imageSrc("escape")).toBe("../../outside.png"); + expect(imageSrc.get("escape")).toBe("../../outside.png"); }); it("routes thread-storage images when the preview caller supplies no routing", () => { - render( + const imageSrc = renderImageSources( { />, ); - expect(imageSrc("absolute")).toBe( + expect(imageSrc.get("absolute")).toBe( "/api/v1/threads/thr_preview/host-files/workspace/generated.png", ); - expect(imageSrc("relative")).toBe( + expect(imageSrc.get("relative")).toBe( "/api/v1/threads/thr_preview/thread-storage/files/docs/images/chart.png", ); - expect(imageSrc("escape")).toBe("../../outside.png"); + expect(imageSrc.get("escape")).toBe("../../outside.png"); }); }); diff --git a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.test.ts b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.test.ts index 1d286b650e9..5f816494f05 100644 --- a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.test.ts +++ b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.test.ts @@ -6,8 +6,11 @@ import { buildGitDiffTarget, COMMITTED_GIT_DIFF_SELECTION, filterDiffFilesByPath, + selectScopedMergeBaseBranch, + selectScopedPendingGitDiffIntent, shouldResetSelectedGitDiffSelection, UNCOMMITTED_GIT_DIFF_SELECTION, + type PendingGitDiffIntent, } from "./gitDiffPanelHelpers"; function makeCommit( @@ -206,3 +209,41 @@ describe("gitDiffPanelHelpers", () => { }); }); }); + +describe("git diff panel scope", () => { + it("never carries a merge-base branch into another environment", () => { + const selection = { branch: "branch-a", environmentId: "env-a" }; + + expect(selectScopedMergeBaseBranch(selection, "env-a")).toBe("branch-a"); + expect(selectScopedMergeBaseBranch(selection, "env-b")).toBeUndefined(); + expect(selectScopedMergeBaseBranch(selection, undefined)).toBeUndefined(); + }); + + it("keeps a pending intent only with the owner that requested it", () => { + const intent: PendingGitDiffIntent = { + environmentId: "env-left", + kind: "file", + path: "left.ts", + threadId: "left", + }; + + expect( + selectScopedPendingGitDiffIntent(intent, { + environmentId: "env-left", + threadId: "left", + }), + ).toBe(intent); + expect( + selectScopedPendingGitDiffIntent(intent, { + environmentId: "env-right", + threadId: "left", + }), + ).toBeNull(); + expect( + selectScopedPendingGitDiffIntent(intent, { + environmentId: "env-left", + threadId: "right", + }), + ).toBeNull(); + }); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts index 1b8f31a7784..ec25aca796b 100644 --- a/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts +++ b/apps/app/src/components/secondary-panel/git-diff/gitDiffPanelHelpers.ts @@ -208,3 +208,34 @@ export function filterDiffFilesByPath< (file) => !excluded.some((isMatch) => fileMatches(file, isMatch)), ); } + +export interface GitDiffPanelScope { + environmentId?: string; + threadId: string; +} + +export interface SelectedMergeBaseBranchState { + branch?: string; + environmentId?: string; +} + +export type PendingGitDiffIntent = GitDiffPanelScope & + ({ kind: "commit"; sha: string } | { kind: "file"; path: string }); + +export function selectScopedMergeBaseBranch( + state: SelectedMergeBaseBranchState, + environmentId: string | undefined, +): string | undefined { + return state.environmentId === environmentId ? state.branch : undefined; +} + +export function selectScopedPendingGitDiffIntent( + intent: PendingGitDiffIntent | null, + scope: GitDiffPanelScope, +): PendingGitDiffIntent | null { + return intent !== null && + intent.environmentId === scope.environmentId && + intent.threadId === scope.threadId + ? intent + : null; +} diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx index 3b1d892db3e..64fa28fd15d 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx @@ -1,201 +1,34 @@ // @vitest-environment jsdom -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Environment } from "@bb/domain"; -import { StrictMode, useState, type ReactNode } from "react"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { useUpdateEnvironment } from "@/hooks/mutations/environment-mutations"; -import { makeEnvironment } from "@bb/test-helpers/domain-fixtures"; -import { useEnvironmentMergeBase } from "./useEnvironmentMergeBase"; +import { useState, type ReactNode } from "react"; +import { afterEach, expect, it } from "vitest"; import { useGitDiffPanel } from "./useGitDiffPanel"; -import { useGitDiffPanelState } from "./useGitDiffPanelState"; - -interface MergeBaseBranchRequest { - environmentId: string; - selectedBranch?: string; -} - -const mergeBaseBranchRequests = vi.hoisted((): MergeBaseBranchRequest[] => []); - -vi.mock("../../../hooks/queries/environment-queries", () => ({ - useEnvironmentMergeBaseBranches: ( - environmentId: string, - options?: { selectedBranch?: string }, - ) => { - mergeBaseBranchRequests.push({ - environmentId, - selectedBranch: options?.selectedBranch, - }); - return { data: undefined, isFetching: false }; - }, - useEnvironmentWorkStatus: () => ({ - data: { - outcome: "available", - workspace: { - mergeBase: { - commits: [ - { sha: "sha-left", shortSha: "sha-lef", subject: "Left commit" }, - { - sha: "sha-right", - shortSha: "sha-rig", - subject: "Right commit", - }, - ], - }, - workingTree: { files: [] }, - }, - }, - }), -})); const noop = () => undefined; -function makeMergeBaseEnvironment( - id: string, - mergeBaseBranch: string, -): Environment { - return makeEnvironment({ - baseBranch: null, - branchName: `bb/${id}`, - createdAt: 1, - hostId: "host-1", - id, - mergeBaseBranch, - path: `/tmp/${id}`, - projectId: "project-1", - updatedAt: 1, - }); -} - function TestRoot({ children }: { children: ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - mutations: { retry: false }, - queries: { retry: false }, - }, - }), - ); + const [queryClient] = useState(() => new QueryClient()); return ( - - {children} - + {children} ); } -function useMergeBaseOwner(environment: Environment, threadId: string) { - const updateEnvironment = useUpdateEnvironment(); - const panel = useGitDiffPanel({ - activeSecondaryTab: null, - clearActiveFileTabs: noop, - defaultMergeBaseBranch: - environment.mergeBaseBranch ?? environment.defaultBranch ?? undefined, - environmentId: environment.id, - mergeBaseBranchOptionsEnabled: true, - setThreadSecondaryPanel: noop, - threadId, - }); - const { effectiveMergeBaseBranch } = useEnvironmentMergeBase({ - environment, - selectedMergeBaseBranch: panel.selectedMergeBaseBranch, - setSelectedMergeBaseBranch: panel.setSelectedMergeBaseBranch, - updateEnvironment, - }); - const diffState = useGitDiffPanelState({ - environmentId: environment.id, - isDiffPanelActive: true, - onClearPendingGitDiffIntent: panel.clearPendingGitDiffIntent, - pendingGitDiffCommitSha: panel.pendingGitDiffCommitSha, - pendingGitDiffScrollPath: panel.pendingGitDiffScrollPath, - requestedMergeBaseBranch: effectiveMergeBaseBranch, - }); - return { ...diffState, ...panel, effectiveMergeBaseBranch }; -} - -beforeEach(() => { - mergeBaseBranchRequests.length = 0; -}); - afterEach(cleanup); -it("keeps production merge-base owners independent through close and reopen", async () => { - const leftEnvironment = makeMergeBaseEnvironment("env-left", "main"); - const rightEnvironment = makeMergeBaseEnvironment("env-right", "release"); - const left = renderHook(() => useMergeBaseOwner(leftEnvironment, "left"), { - wrapper: TestRoot, - }); - const right = renderHook(() => useMergeBaseOwner(rightEnvironment, "right"), { - wrapper: TestRoot, - }); - - await waitFor(() => { - expect(left.result.current.effectiveMergeBaseBranch).toBe("main"); - expect(right.result.current.effectiveMergeBaseBranch).toBe("release"); - }); - - right.unmount(); - expect(left.result.current.effectiveMergeBaseBranch).toBe("main"); - - const reopenedRight = renderHook( - () => useMergeBaseOwner(rightEnvironment, "right"), - { wrapper: TestRoot }, - ); - await waitFor(() => { - expect(left.result.current.effectiveMergeBaseBranch).toBe("main"); - expect(reopenedRight.result.current.effectiveMergeBaseBranch).toBe( - "release", - ); - }); - - expect( - mergeBaseBranchRequests - .filter(({ environmentId }) => environmentId === "env-left") - .every(({ selectedBranch }) => selectedBranch === "main"), - ).toBe(true); - expect( - mergeBaseBranchRequests - .filter(({ environmentId }) => environmentId === "env-right") - .every(({ selectedBranch }) => selectedBranch === "release"), - ).toBe(true); -}); - -it("never queries a new environment with the previous environment branch", async () => { - const environmentA = makeMergeBaseEnvironment("env-a", "branch-a"); - const environmentB = makeMergeBaseEnvironment("env-b", "branch-b"); - const owner = renderHook( - ({ environment }) => useMergeBaseOwner(environment, "switching"), - { initialProps: { environment: environmentA }, wrapper: TestRoot }, - ); - await waitFor(() => - expect(owner.result.current.effectiveMergeBaseBranch).toBe("branch-a"), - ); - - mergeBaseBranchRequests.length = 0; - owner.rerender({ environment: environmentB }); - - await waitFor(() => - expect(owner.result.current.effectiveMergeBaseBranch).toBe("branch-b"), - ); - const environmentBRequests = mergeBaseBranchRequests.filter( - ({ environmentId }) => environmentId === "env-b", - ); - expect(environmentBRequests.length).toBeGreaterThan(0); - expect( - environmentBRequests.every( - ({ selectedBranch }) => selectedBranch === "branch-b", - ), - ).toBe(true); -}); - it("does not revive an unconsumed file intent after navigating away and back", () => { - const environmentA = makeMergeBaseEnvironment("env-a", "branch-a"); - const environmentB = makeMergeBaseEnvironment("env-b", "branch-b"); const owner = renderHook( - ({ environment, threadId }) => useMergeBaseOwner(environment, threadId), + ({ environmentId, threadId }) => + useGitDiffPanel({ + activeSecondaryTab: null, + clearActiveFileTabs: noop, + environmentId, + setThreadSecondaryPanel: noop, + threadId, + }), { - initialProps: { environment: environmentA, threadId: "thread-a" }, + initialProps: { environmentId: "env-a", threadId: "thread-a" }, wrapper: TestRoot, }, ); @@ -203,43 +36,9 @@ it("does not revive an unconsumed file intent after navigating away and back", ( act(() => owner.result.current.openDiffFile("left.ts")); expect(owner.result.current.pendingGitDiffScrollPath).toBe("left.ts"); - owner.rerender({ environment: environmentB, threadId: "thread-b" }); + owner.rerender({ environmentId: "env-b", threadId: "thread-b" }); expect(owner.result.current.pendingGitDiffScrollPath).toBeNull(); - owner.rerender({ environment: environmentA, threadId: "thread-a" }); + owner.rerender({ environmentId: "env-a", threadId: "thread-a" }); expect(owner.result.current.pendingGitDiffScrollPath).toBeNull(); }); - -it("keeps delayed file and commit intents with the owner that requested them", async () => { - const left = renderHook( - () => - useMergeBaseOwner(makeMergeBaseEnvironment("env-left", "main"), "left"), - { wrapper: TestRoot }, - ); - const right = renderHook( - () => - useMergeBaseOwner( - makeMergeBaseEnvironment("env-right", "release"), - "right", - ), - { wrapper: TestRoot }, - ); - - act(() => left.result.current.openDiffFile("left.ts")); - expect(left.result.current.pendingGitDiffScrollPath).toBe("left.ts"); - expect(right.result.current.pendingGitDiffScrollPath).toBeNull(); - - act(() => right.result.current.clearPendingGitDiffIntent()); - expect(left.result.current.pendingGitDiffScrollPath).toBe("left.ts"); - - act(() => left.result.current.clearPendingGitDiffIntent()); - expect(left.result.current.pendingGitDiffScrollPath).toBeNull(); - - act(() => left.result.current.openCommitDiff("sha-left")); - await waitFor(() => { - expect(left.result.current.gitDiffSelectValue).toBe("sha-left"); - expect(left.result.current.pendingGitDiffCommitSha).toBeNull(); - }); - expect(right.result.current.gitDiffSelectValue).toBe("all"); - expect(right.result.current.pendingGitDiffCommitSha).toBeNull(); -}); diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts index 70d97d871f2..8f233ddb58d 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts @@ -3,6 +3,12 @@ import type { GitBranchRefClassification } from "@bb/domain"; import { useEnvironmentMergeBaseBranches } from "../../../hooks/queries/environment-queries"; import type { SecondaryFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; +import { + selectScopedMergeBaseBranch, + selectScopedPendingGitDiffIntent, + type PendingGitDiffIntent, + type SelectedMergeBaseBranchState, +} from "./gitDiffPanelHelpers"; type ThreadSecondaryPanelSetter = ( panel: ThreadSecondaryPanelTab | null, @@ -18,25 +24,6 @@ interface UseGitDiffPanelParams { threadId: string; } -interface SelectedMergeBaseBranchState { - branch?: string; - environmentId?: string; -} - -type PendingGitDiffIntent = - | { - environmentId?: string; - kind: "commit"; - sha: string; - threadId: string; - } - | { - environmentId?: string; - kind: "file"; - path: string; - threadId: string; - }; - function prependSelectedBranch( list: string[] | undefined, selectedRef: GitBranchRefClassification | null | undefined, @@ -62,10 +49,10 @@ export function useGitDiffPanel({ }: UseGitDiffPanelParams) { const [selectedMergeBaseBranchState, setSelectedMergeBaseBranchState] = useState({ environmentId }); - const selectedMergeBaseBranch = - selectedMergeBaseBranchState.environmentId === environmentId - ? selectedMergeBaseBranchState.branch - : undefined; + const selectedMergeBaseBranch = selectScopedMergeBaseBranch( + selectedMergeBaseBranchState, + environmentId, + ); const setSelectedMergeBaseBranch = useCallback( (branch: string | undefined) => { setSelectedMergeBaseBranchState({ branch, environmentId }); @@ -74,12 +61,10 @@ export function useGitDiffPanel({ ); const [pendingGitDiffIntent, setPendingGitDiffIntent] = useState(null); - const currentPendingGitDiffIntent = - pendingGitDiffIntent !== null && - pendingGitDiffIntent.environmentId === environmentId && - pendingGitDiffIntent.threadId === threadId - ? pendingGitDiffIntent - : null; + const currentPendingGitDiffIntent = selectScopedPendingGitDiffIntent( + pendingGitDiffIntent, + { environmentId, threadId }, + ); const pendingGitDiffCommitSha = currentPendingGitDiffIntent?.kind === "commit" ? currentPendingGitDiffIntent.sha @@ -90,11 +75,10 @@ export function useGitDiffPanel({ : null; const clearPendingGitDiffIntent = useCallback(() => { setPendingGitDiffIntent((current) => - current !== null && - current.environmentId === environmentId && - current.threadId === threadId - ? null - : current, + selectScopedPendingGitDiffIntent(current, { environmentId, threadId }) === + null + ? current + : null, ); }, [environmentId, threadId]); const [mergeBaseBranchSearchQuery, setMergeBaseBranchSearchQuery] = diff --git a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.ts b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.ts new file mode 100644 index 00000000000..5bceb6608c6 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + resolveGitDiffDisplayMode, + resolveGitDiffDisplayModeChangeTarget, + resolveIsWideEnoughForSplit, +} from "./useResponsiveGitDiffPanelDisplay"; + +const NARROW_WIDTH_PX = 500; +const WIDE_WIDTH_PX = 900; + +describe("resolveGitDiffDisplayMode", () => { + it("resolves display mode from viewport, preference, and width", () => { + const resolve = ( + isCompactViewport: boolean, + compactDisplayMode: "unified" | "split" | null, + displayModePreference: "unified" | "split" | null, + isWideEnoughForSplit: boolean | null, + ) => + resolveGitDiffDisplayMode({ + isCompactViewport, + compactDisplayMode, + displayModePreference, + isWideEnoughForSplit, + }); + + expect(resolve(true, null, "split", true)).toBe("unified"); + expect(resolve(true, "split", "unified", false)).toBe("split"); + expect(resolve(true, "unified", "split", true)).toBe("unified"); + + expect(resolve(false, null, null, null)).toBe("unified"); + expect(resolve(false, null, null, false)).toBe("unified"); + expect(resolve(false, null, null, true)).toBe("split"); + expect(resolve(false, "split", null, false)).toBe("unified"); + expect(resolve(false, null, "split", false)).toBe("split"); + expect(resolve(false, null, "unified", true)).toBe("unified"); + }); +}); + +describe("resolveIsWideEnoughForSplit", () => { + it.each([ + { + name: "follows the width of an open panel", + input: { + isSecondaryPanelOpen: true, + nextWidth: WIDE_WIDTH_PX, + }, + expected: true, + }, + { + name: "drops back below the breakpoint", + input: { + isSecondaryPanelOpen: true, + nextWidth: NARROW_WIDTH_PX, + }, + expected: false, + }, + { + name: "ignores width changes while the panel is closed", + input: { + isSecondaryPanelOpen: false, + nextWidth: WIDE_WIDTH_PX, + }, + expected: null, + }, + { + name: "ignores an unmeasured width", + input: { + isSecondaryPanelOpen: true, + nextWidth: undefined, + }, + expected: null, + }, + ])("$name", ({ input, expected }) => { + expect(resolveIsWideEnoughForSplit(input)).toBe(expected); + }); +}); + +describe("resolveGitDiffDisplayModeChangeTarget", () => { + it("keeps a compact viewport toggle in the session and stores desktop choices", () => { + expect(resolveGitDiffDisplayModeChangeTarget(true)).toBe("compact-session"); + expect(resolveGitDiffDisplayModeChangeTarget(false)).toBe( + "stored-preference", + ); + }); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx deleted file mode 100644 index a1048b62a38..00000000000 --- a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -// @vitest-environment jsdom - -import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; -import { act, cleanup, renderHook } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, expect, it } from "vitest"; -import { - GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, - GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY, - useGitDiffLineOverflowModePreference, -} from "@/lib/git-diff-view-preferences"; -import { - resolveGitDiffDisplayMode, - useResponsiveGitDiffPanelDisplay, -} from "./useResponsiveGitDiffPanelDisplay"; - -const NARROW_WIDTH_PX = 500; -const WIDE_WIDTH_PX = 900; - -afterEach(() => { - cleanup(); - window.localStorage.clear(); -}); - -function renderDisplay() { - return renderHook(() => - useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: true }), - ); -} - -function renderCompactDisplay() { - return renderHook( - () => useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: true }), - { - wrapper: ({ children }: { children: ReactNode }) => ( - - {children} - - ), - }, - ); -} - -it("follows panel width while no explicit preference is stored", () => { - const { result } = renderDisplay(); - - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - expect(result.current.gitDiffDisplayMode).toBe("split"); - - act(() => { - result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); - }); - expect(result.current.gitDiffDisplayMode).toBe("unified"); - - expect( - window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY), - ).toBeNull(); -}); - -it("honors an explicit choice at any panel width", () => { - const { result } = renderDisplay(); - - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - act(() => { - result.current.handleGitDiffDisplayModeChange("unified"); - }); - act(() => { - result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); - }); - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - - expect(result.current.gitDiffDisplayMode).toBe("unified"); -}); - -it("keeps a stored split choice on a panel narrower than the breakpoint", () => { - const { result } = renderDisplay(); - - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - act(() => { - result.current.handleGitDiffDisplayModeChange("split"); - }); - act(() => { - result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); - }); - - expect(result.current.gitDiffDisplayMode).toBe("split"); -}); - -it("keeps an explicit choice across closing and reopening the panel", () => { - const { result, rerender } = renderHook( - (props: { isSecondaryPanelOpen: boolean }) => - useResponsiveGitDiffPanelDisplay(props), - { initialProps: { isSecondaryPanelOpen: true } }, - ); - - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - act(() => { - result.current.handleGitDiffDisplayModeChange("unified"); - }); - - rerender({ isSecondaryPanelOpen: false }); - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - rerender({ isSecondaryPanelOpen: true }); - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - - expect(result.current.gitDiffDisplayMode).toBe("unified"); -}); - -it("restores the stored display mode on a fresh mount", () => { - const first = renderDisplay(); - act(() => { - first.result.current.handleSecondaryPanelWidthChange(NARROW_WIDTH_PX); - }); - act(() => { - first.result.current.handleGitDiffDisplayModeChange("split"); - }); - cleanup(); - - const { result } = renderDisplay(); - expect(result.current.gitDiffDisplayMode).toBe("split"); -}); - -it("ignores an unrecognized stored display mode", () => { - window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "sideways"); - - const { result } = renderDisplay(); - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - - expect(result.current.gitDiffDisplayMode).toBe("split"); -}); - -it("does not track width changes while the panel is closed", () => { - const { result } = renderHook(() => - useResponsiveGitDiffPanelDisplay({ isSecondaryPanelOpen: false }), - ); - - act(() => { - result.current.handleSecondaryPanelWidthChange(WIDE_WIDTH_PX); - }); - - expect(result.current.gitDiffDisplayMode).toBe("unified"); -}); - -it("defaults a compact viewport to unified even with split stored", () => { - window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "split"); - - const { result } = renderCompactDisplay(); - - expect(result.current.gitDiffDisplayMode).toBe("unified"); -}); - -it("lets a compact viewport toggle to split", () => { - const { result } = renderCompactDisplay(); - - act(() => { - result.current.handleGitDiffDisplayModeChange("split"); - }); - - expect(result.current.gitDiffDisplayMode).toBe("split"); -}); - -it("does not persist a compact viewport toggle", () => { - const first = renderCompactDisplay(); - act(() => { - first.result.current.handleGitDiffDisplayModeChange("split"); - }); - expect( - window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY), - ).toBeNull(); - cleanup(); - - const { result } = renderCompactDisplay(); - expect(result.current.gitDiffDisplayMode).toBe("unified"); -}); - -it("leaves an existing stored preference untouched when toggling on compact", () => { - window.localStorage.setItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY, "split"); - - const compact = renderCompactDisplay(); - act(() => { - compact.result.current.handleGitDiffDisplayModeChange("unified"); - }); - cleanup(); - - expect(window.localStorage.getItem(GIT_DIFF_DISPLAY_MODE_STORAGE_KEY)).toBe( - "split", - ); - - const { result } = renderDisplay(); - expect(result.current.gitDiffDisplayMode).toBe("split"); -}); - -it("resolves display mode from viewport, preference, and width", () => { - const resolve = ( - isCompactViewport: boolean, - compactDisplayMode: "unified" | "split" | null, - displayModePreference: "unified" | "split" | null, - isWideEnoughForSplit: boolean | null, - ) => - resolveGitDiffDisplayMode({ - isCompactViewport, - compactDisplayMode, - displayModePreference, - isWideEnoughForSplit, - }); - - expect(resolve(true, null, "split", true)).toBe("unified"); - expect(resolve(true, "split", "unified", false)).toBe("split"); - expect(resolve(true, "unified", "split", true)).toBe("unified"); - - expect(resolve(false, null, null, null)).toBe("unified"); - expect(resolve(false, null, null, false)).toBe("unified"); - expect(resolve(false, null, null, true)).toBe("split"); - expect(resolve(false, "split", null, false)).toBe("unified"); - expect(resolve(false, null, "split", false)).toBe("split"); - expect(resolve(false, null, "unified", true)).toBe("unified"); -}); - -it("restores the stored line overflow mode on a fresh mount", () => { - const first = renderHook(() => useGitDiffLineOverflowModePreference()); - act(() => { - first.result.current[1]("wrap"); - }); - expect( - window.localStorage.getItem(GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY), - ).toBe("wrap"); - cleanup(); - - const { result } = renderHook(() => useGitDiffLineOverflowModePreference()); - expect(result.current[0]).toBe("wrap"); -}); - -it("falls back to scroll for an unrecognized stored line overflow mode", () => { - window.localStorage.setItem(GIT_DIFF_LINE_OVERFLOW_MODE_STORAGE_KEY, "fold"); - - const { result } = renderHook(() => useGitDiffLineOverflowModePreference()); - expect(result.current[0]).toBe("scroll"); -}); diff --git a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts index d46ed02e90c..f20e9d6cbd3 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useResponsiveGitDiffPanelDisplay.ts @@ -33,6 +33,29 @@ export function resolveGitDiffDisplayMode({ return isWideEnoughForSplit === true ? "split" : "unified"; } +export function resolveIsWideEnoughForSplit({ + isSecondaryPanelOpen, + nextWidth, +}: { + isSecondaryPanelOpen: boolean; + nextWidth: number | undefined; +}): boolean | null { + if (!isSecondaryPanelOpen || nextWidth === undefined) { + return null; + } + return nextWidth >= GIT_DIFF_SPLIT_VIEW_MIN_WIDTH_PX; +} + +export type GitDiffDisplayModeChangeTarget = + | "compact-session" + | "stored-preference"; + +export function resolveGitDiffDisplayModeChangeTarget( + isCompactViewport: boolean, +): GitDiffDisplayModeChangeTarget { + return isCompactViewport ? "compact-session" : "stored-preference"; +} + interface UseResponsiveGitDiffPanelDisplayArgs { isSecondaryPanelOpen: boolean; } @@ -52,11 +75,13 @@ export function useResponsiveGitDiffPanelDisplay({ const handleSecondaryPanelWidthChange = useCallback( (nextWidth) => { - if (!isSecondaryPanelOpen || nextWidth === undefined) { + const nextWideEnough = resolveIsWideEnoughForSplit({ + isSecondaryPanelOpen, + nextWidth, + }); + if (nextWideEnough === null) { return; } - - const nextWideEnough = nextWidth >= GIT_DIFF_SPLIT_VIEW_MIN_WIDTH_PX; setIsWideEnoughForSplit((current) => current === nextWideEnough ? current : nextWideEnough, ); @@ -67,7 +92,10 @@ export function useResponsiveGitDiffPanelDisplay({ const handleGitDiffDisplayModeChange = useCallback( (nextMode) => { - if (isCompactViewport) { + if ( + resolveGitDiffDisplayModeChangeTarget(isCompactViewport) === + "compact-session" + ) { setCompactDisplayMode(nextMode); return; } diff --git a/apps/app/src/components/secondary-panel/usePanelBrowser.test.tsx b/apps/app/src/components/secondary-panel/usePanelBrowser.test.tsx index 95e345f1945..61da3200ebb 100644 --- a/apps/app/src/components/secondary-panel/usePanelBrowser.test.tsx +++ b/apps/app/src/components/secondary-panel/usePanelBrowser.test.tsx @@ -1,125 +1,96 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { usePanelBrowser } from "./usePanelBrowser"; - -const desktop = vi.hoisted(() => ({ - scopedOpenTab: null as - | null - | ((event: { tabId: string; url: string }) => void), - openTab: null as null | ((event: { url: string }) => void), - scoped: true, -})); -const external = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/bb-desktop", async (importOriginal) => ({ - ...(await importOriginal()), - getDesktopBrowserApi: () => ({ - onScopedOpenTab: desktop.scoped - ? (listener: (event: { tabId: string; url: string }) => void) => { - desktop.scopedOpenTab = listener; - return () => undefined; - } - : undefined, - onOpenTab: (listener: (event: { url: string }) => void) => { - desktop.openTab = listener; - return () => undefined; +import { renderHookStatically } from "@/test/render-hook-statically"; +import { describe, expect, it, vi } from "vitest"; +import { + subscribeDesktopOpenRequests, + usePanelBrowser, + type PanelBrowser, +} from "./usePanelBrowser"; + +type ScopedListener = (event: { tabId: string; url: string }) => void; +type OpenListener = (event: { url: string }) => void; + +function fakeBrowserApi({ scoped }: { scoped: boolean }) { + const listeners: { open: OpenListener[]; scoped: ScopedListener[] } = { + open: [], + scoped: [], + }; + return { + listeners, + api: { + onOpenTab: (listener: OpenListener) => { + listeners.open.push(listener); + return () => undefined; + }, + onScopedOpenTab: scoped + ? (listener: ScopedListener) => { + listeners.scoped.push(listener); + return () => undefined; + } + : undefined, }, - }), -})); - -vi.mock("@/lib/url-open-routing", () => ({ - openUrlInExternalBrowser: external, -})); - -interface SurfaceCase { - name: string; - available: boolean; + }; } -const SURFACES: readonly SurfaceCase[] = [ - { name: "thread view", available: true }, - { name: "New thread screen without a thread", available: false }, - { name: "plugin page", available: true }, -]; - -function renderSurface(surface: SurfaceCase, isFocused = true) { - const openTab = vi.fn(({ url }: { url?: string }) => ({ - id: `browser:${url ?? ""}`, - kind: "browser", - })); - const reveal = vi.fn(); - const { result } = renderHook(() => +function readPanelBrowser(available: boolean): PanelBrowser { + return renderHookStatically(() => usePanelBrowser({ - available: surface.available, - browserTabs: [{ id: "browser:mine" }], - isFocused, - openTab: (request) => - openTab(request.kind === "browser" ? request : { url: undefined }), - reveal, + available, + browserTabs: [], + isFocused: true, + openTab: () => null, + reveal: () => undefined, }), ); - return { openTab, result, reveal }; } -afterEach(() => { - cleanup(); - external.mockReset(); - desktop.scopedOpenTab = null; - desktop.openTab = null; - desktop.scoped = true; +describe("usePanelBrowser", () => { + it("offers a blank-tab opener only where the panel browser is available", () => { + expect(readPanelBrowser(true).open).toEqual(expect.any(Function)); + expect(readPanelBrowser(false).open).toBeNull(); + }); }); -describe.each(SURFACES)("panel browser on the $name", (surface) => { - it("opens a blank tab with address focus only when available", () => { - const { openTab, result, reveal } = renderSurface(surface); - expect(result.current.open === null).toBe(!surface.available); - if (result.current.open === null) return; - - act(() => result.current.open?.()); - - expect(openTab).toHaveBeenCalledWith({ kind: "browser", url: "" }); - expect(reveal).toHaveBeenCalledTimes(1); - const request = result.current.addressFocusRequest; - expect(request?.tabId).toBe("browser:"); - act(() => { - if (request !== null) - result.current.handleAddressFocusRequestConsumed(request); +describe("subscribeDesktopOpenRequests", () => { + it("opens scoped desktop new-tab requests only for the surface's own tabs", () => { + const { api, listeners } = fakeBrowserApi({ scoped: true }); + const openUrl = vi.fn(); + subscribeDesktopOpenRequests(api, { + browserTabIds: new Set(["browser:mine"]), + isFocused: false, + openUrl, }); - expect(result.current.addressFocusRequest).toBeNull(); - }); - it("routes desktop new-tab requests through the link preference", () => { - const { openTab } = renderSurface(surface); - - act(() => - desktop.scopedOpenTab?.({ - tabId: "browser:other", - url: "https://a.test", - }), + listeners.scoped.forEach((listener) => + listener({ tabId: "browser:other", url: "https://a.test" }), ); - expect(openTab).not.toHaveBeenCalled(); - expect(external).not.toHaveBeenCalled(); + expect(openUrl).not.toHaveBeenCalled(); - act(() => - desktop.scopedOpenTab?.({ tabId: "browser:mine", url: "https://a.test" }), + listeners.scoped.forEach((listener) => + listener({ tabId: "browser:mine", url: "https://a.test" }), ); - if (surface.available) { - expect(openTab.mock.calls.length + external.mock.calls.length).toBe(1); - } else { - expect(openTab).not.toHaveBeenCalled(); - expect(external).toHaveBeenCalledWith("https://a.test"); - } + expect(openUrl).toHaveBeenCalledWith("https://a.test"); + expect(listeners.open).toHaveLength(0); }); it("listens to unscoped desktop requests only while focused", () => { - desktop.scoped = false; - renderSurface(surface, false); - expect(desktop.openTab).toBeNull(); - - cleanup(); - renderSurface(surface, true); - expect(desktop.openTab).not.toBeNull(); + const unfocused = fakeBrowserApi({ scoped: false }); + subscribeDesktopOpenRequests(unfocused.api, { + browserTabIds: new Set(), + isFocused: false, + openUrl: vi.fn(), + }); + expect(unfocused.listeners.open).toHaveLength(0); + + const focused = fakeBrowserApi({ scoped: false }); + const openUrl = vi.fn(); + subscribeDesktopOpenRequests(focused.api, { + browserTabIds: new Set(), + isFocused: true, + openUrl, + }); + focused.listeners.open.forEach((listener) => + listener({ url: "https://a.test" }), + ); + expect(openUrl).toHaveBeenCalledWith("https://a.test"); }); }); diff --git a/apps/app/src/components/secondary-panel/usePanelBrowser.ts b/apps/app/src/components/secondary-panel/usePanelBrowser.ts index bbebf5d42a2..fa7de4ee52a 100644 --- a/apps/app/src/components/secondary-panel/usePanelBrowser.ts +++ b/apps/app/src/components/secondary-panel/usePanelBrowser.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { BbDesktopBrowserApi } from "@bb/desktop-contract"; import { getDesktopBrowserApi } from "@/lib/bb-desktop"; import { openUrlByPreference, @@ -28,6 +29,30 @@ export interface PanelBrowser { openUrl: (url: string) => boolean; } +export function subscribeDesktopOpenRequests( + browserApi: Pick | null, + { + browserTabIds, + isFocused, + openUrl, + }: { + browserTabIds: ReadonlySet; + isFocused: boolean; + openUrl: (url: string) => void; + }, +): (() => void) | undefined { + if (browserApi === null) return; + if (browserApi.onScopedOpenTab) { + return browserApi.onScopedOpenTab(({ tabId, url }) => { + if (browserTabIds.has(tabId)) openUrl(url); + }); + } + if (!isFocused) return; + return browserApi.onOpenTab(({ url }) => { + if (!isRoutePath({ path: url })) openUrl(url); + }); +} + export function usePanelBrowser({ available, browserTabs, @@ -81,19 +106,15 @@ export function usePanelBrowser({ () => new Set(browserTabs.map((tab) => tab.id)), [browserTabs], ); - useEffect(() => { - const browserApi = getDesktopBrowserApi(); - if (browserApi === null) return; - if (browserApi.onScopedOpenTab) { - return browserApi.onScopedOpenTab(({ tabId, url }) => { - if (browserTabIds.has(tabId)) openUrl(url); - }); - } - if (!isFocused) return; - return browserApi.onOpenTab(({ url }) => { - if (!isRoutePath({ path: url })) openUrl(url); - }); - }, [browserTabIds, isFocused, openUrl]); + useEffect( + () => + subscribeDesktopOpenRequests(getDesktopBrowserApi(), { + browserTabIds, + isFocused, + openUrl, + }), + [browserTabIds, isFocused, openUrl], + ); return { addressFocusRequest, diff --git a/apps/app/src/components/secondary-panel/usePanelFiles.test.tsx b/apps/app/src/components/secondary-panel/usePanelFiles.test.tsx index 5b8c5bcad3f..3d432cac099 100644 --- a/apps/app/src/components/secondary-panel/usePanelFiles.test.tsx +++ b/apps/app/src/components/secondary-panel/usePanelFiles.test.tsx @@ -1,9 +1,10 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHookStatically } from "@/test/render-hook-statically"; +import { describe, expect, it, vi } from "vitest"; import type { ExperimentalLiveFileTarget } from "@get-bb/plugin-sdk"; -import { usePanelFiles, type PanelFileScope } from "./usePanelFiles"; +import { + usePanelFiles, + type PanelFileScope, +} from "./usePanelFiles"; import type { OpenSecondaryPanelTabRequest } from "./useThreadFileTabs"; interface SurfaceCase { @@ -74,14 +75,12 @@ function renderSurface(surface: SurfaceCase, available = true) { id: "tab", })); const reveal = vi.fn(); - const { result } = renderHook(() => + const panelFiles = renderHookStatically(() => usePanelFiles({ available, openTab, reveal, scope: surface.scope }), ); - return { openTab, result, reveal }; + return { openTab, panelFiles, reveal }; } -afterEach(cleanup); - describe.each(SURFACES)("panel files on the $name", (surface) => { it.each([ ["ownWorkspace", OWN_WORKSPACE], @@ -89,11 +88,8 @@ describe.each(SURFACES)("panel files on the $name", (surface) => { ["ownStorage", OWN_STORAGE], ["otherWorkspace", OTHER_WORKSPACE], ] as const)("applies the surface rule to a %s target", (caseName, target) => { - const { openTab, result, reveal } = renderSurface(surface); - let accepted = false; - act(() => { - accepted = result.current.openFilePreview({ target, location: null }); - }); + const { openTab, panelFiles, reveal } = renderSurface(surface); + const accepted = panelFiles.openFilePreview({ target, location: null }); expect(accepted).toBe(surface.accepts[caseName]); expect(openTab).toHaveBeenCalledTimes(accepted ? 1 : 0); @@ -102,11 +98,9 @@ describe.each(SURFACES)("panel files on the $name", (surface) => { it("opens host files in the same tab shape core uses", () => { if (!surface.accepts.ownHost) return; - const { openTab, result } = renderSurface(surface); - act(() => { - result.current.openFilePreview({ target: OWN_HOST, location: null }); - result.current.openHostFile({ lineRange: null, path: OWN_HOST.path }); - }); + const { openTab, panelFiles } = renderSurface(surface); + panelFiles.openFilePreview({ target: OWN_HOST, location: null }); + panelFiles.openHostFile({ lineRange: null, path: OWN_HOST.path }); const [apiRequest, coreRequest] = openTab.mock.calls.map( ([request]) => request, @@ -119,17 +113,17 @@ describe.each(SURFACES)("panel files on the $name", (surface) => { }); it("declines malformed targets and unavailable surfaces", () => { - const { result: available } = renderSurface(surface); - const { result: unavailable, openTab } = renderSurface(surface, false); + const { panelFiles: available } = renderSurface(surface); + const { panelFiles: unavailable, openTab } = renderSurface(surface, false); expect( - available.current.openFilePreview({ + available.openFilePreview({ target: { ...OWN_WORKSPACE, path: "../escape" }, location: null, }), ).toBe(false); expect( - unavailable.current.openFilePreview({ + unavailable.openFilePreview({ target: OWN_WORKSPACE, location: null, }), diff --git a/apps/app/src/components/secondary-panel/usePanelPluginPanels.test.tsx b/apps/app/src/components/secondary-panel/usePanelPluginPanels.test.tsx index fb4e65c3e17..d581342cba0 100644 --- a/apps/app/src/components/secondary-panel/usePanelPluginPanels.test.tsx +++ b/apps/app/src/components/secondary-panel/usePanelPluginPanels.test.tsx @@ -1,13 +1,16 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; +import { renderHookStatically } from "@/test/render-hook-statically"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getActiveThreadPanelOpener, - resetActiveThreadPanelOpenerForTest, -} from "@/components/plugin/plugin-thread-panel-navigation"; +import type { PluginThreadPanelOpenHandler } from "@/components/plugin/plugin-thread-panel-navigation"; import { usePanelPluginPanels } from "./usePanelPluginPanels"; +const publishThreadPanelOpener = vi.hoisted(() => + vi.fn<(opener: PluginThreadPanelOpenHandler, isActive: boolean) => void>(), +); + +vi.mock("@/components/plugin/plugin-thread-panel-navigation", () => ({ + usePublishThreadPanelOpener: publishThreadPanelOpener, +})); + interface SurfaceCase { name: string; slot: "threadPanelAction" | "experimental_newThreadPanelAction"; @@ -28,7 +31,7 @@ const SURFACES: readonly SurfaceCase[] = [ function renderSurface(surface: SurfaceCase, isFocused = true) { const openPluginPanel = vi.fn(); const reveal = vi.fn(); - const { result } = renderHook(() => + const openThreadPanel = renderHookStatically(() => usePanelPluginPanels({ actions: surface.actions, isFocused, @@ -37,28 +40,25 @@ function renderSurface(surface: SurfaceCase, isFocused = true) { slot: surface.slot, }), ); - return { openPluginPanel, result, reveal }; + return { openPluginPanel, openThreadPanel, reveal }; } afterEach(() => { - cleanup(); - resetActiveThreadPanelOpenerForTest(); + publishThreadPanelOpener.mockReset(); vi.restoreAllMocks(); }); describe.each(SURFACES)("panel plugin tabs on the $name", (surface) => { it("opens a registered action with its default title and JSON params", () => { - const { openPluginPanel, result, reveal } = renderSurface(surface); - let accepted = false; - act(() => { - accepted = result.current({ + const { openPluginPanel, openThreadPanel, reveal } = renderSurface(surface); + + expect( + openThreadPanel({ pluginId: "demo", actionId: "board", params: { id: 7 }, - }); - }); - - expect(accepted).toBe(true); + }), + ).toBe(true); expect(openPluginPanel).toHaveBeenCalledWith({ pluginId: "demo", actionId: "board", @@ -70,16 +70,16 @@ describe.each(SURFACES)("panel plugin tabs on the $name", (surface) => { it("declines unknown actions and non-JSON params without revealing", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); - const { openPluginPanel, result, reveal } = renderSurface(surface); + const { openPluginPanel, openThreadPanel, reveal } = renderSurface(surface); - expect(result.current({ pluginId: "demo", actionId: "missing" })).toBe( + expect(openThreadPanel({ pluginId: "demo", actionId: "missing" })).toBe( false, ); - expect(result.current({ pluginId: "other", actionId: "board" })).toBe( + expect(openThreadPanel({ pluginId: "other", actionId: "board" })).toBe( false, ); expect( - result.current({ + openThreadPanel({ pluginId: "demo", actionId: "board", params: { when: new Date() } as never, @@ -89,13 +89,17 @@ describe.each(SURFACES)("panel plugin tabs on the $name", (surface) => { expect(reveal).not.toHaveBeenCalled(); }); - it("is the opener plugin commands use while the surface is focused", () => { - renderSurface(surface, false); - expect(getActiveThreadPanelOpener()).toBeNull(); + it("publishes itself as the plugin command opener only while focused", () => { + const unfocused = renderSurface(surface, false); + expect(publishThreadPanelOpener).toHaveBeenLastCalledWith( + unfocused.openThreadPanel, + false, + ); - cleanup(); - const { openPluginPanel } = renderSurface(surface, true); - getActiveThreadPanelOpener()?.({ pluginId: "demo", actionId: "board" }); - expect(openPluginPanel).toHaveBeenCalledTimes(1); + const focused = renderSurface(surface, true); + expect(publishThreadPanelOpener).toHaveBeenLastCalledWith( + focused.openThreadPanel, + true, + ); }); }); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.ts b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.ts new file mode 100644 index 00000000000..8b7c5fc795a --- /dev/null +++ b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.ts @@ -0,0 +1,74 @@ +import type { WorkspaceFile } from "@bb/server-contract"; +import { describe, expect, it } from "vitest"; +import { + filterThreadStorageFiles, + initialThreadStorageFolderState, + syncThreadStorageFolderState, + toggleThreadStorageFolderChain, +} from "./useThreadStorageBrowser"; + +const FILES: WorkspaceFile[] = [ + { name: "notes.md", path: "docs/notes.md" }, + { name: "Screenshot.png", path: "Attachments/Screenshot.png" }, +]; + +describe("filterThreadStorageFiles", () => { + it("filters stored files by path without case sensitivity", () => { + expect( + filterThreadStorageFiles(FILES, "attach").map((file) => file.path), + ).toEqual(["Attachments/Screenshot.png"]); + expect(filterThreadStorageFiles(FILES, "")).toHaveLength(2); + }); +}); + +describe("thread storage folder state", () => { + it("opens the folders above a newly selected file", () => { + const empty = initialThreadStorageFolderState("thr_a", null); + expect(empty.expandedFolders.size).toBe(0); + + const selected = syncThreadStorageFolderState(empty, { + selectedPath: "docs/notes.md", + threadId: "thr_a", + }); + expect([...selected.expandedFolders]).toEqual(["docs"]); + expect([...selected.foldersShowingAll]).toEqual(["docs"]); + + const deselected = syncThreadStorageFolderState(selected, { + selectedPath: null, + threadId: "thr_a", + }); + expect(deselected.lastSelectedPath).toBe("docs/notes.md"); + expect( + toggleThreadStorageFolderChain(deselected.expandedFolders, ["docs"]).has( + "docs", + ), + ).toBe(false); + }); + + it("opens and closes every folder in a merged chain together", () => { + const opened = toggleThreadStorageFolderChain(new Set(), [ + "qa", + "qa/shots", + ]); + expect([...opened]).toEqual(["qa", "qa/shots"]); + + expect( + toggleThreadStorageFolderChain(opened, ["qa", "qa/shots"]).size, + ).toBe(0); + }); + + it("starts each thread with only the selected file's folders open", () => { + const threadA = { + ...initialThreadStorageFolderState("thr_a", null), + expandedFolders: new Set(["docs"]), + foldersShowingAll: new Set(["docs"]), + }; + + const threadB = syncThreadStorageFolderState(threadA, { + selectedPath: null, + threadId: "thr_b", + }); + expect(threadB.expandedFolders.size).toBe(0); + expect(threadB.foldersShowingAll.size).toBe(0); + }); +}); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx deleted file mode 100644 index b1318e7b9d6..00000000000 --- a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// @vitest-environment jsdom -import { act, cleanup, renderHook } from "@testing-library/react"; -import type { WorkspaceFile } from "@bb/server-contract"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useThreadStorageBrowser } from "./useThreadStorageBrowser"; - -const FILES: WorkspaceFile[] = [ - { name: "notes.md", path: "docs/notes.md" }, - { name: "Screenshot.png", path: "Attachments/Screenshot.png" }, -]; - -afterEach(cleanup); - -describe("useThreadStorageBrowser", () => { - it("filters stored files by path without case sensitivity", () => { - const { result } = renderHook(() => - useThreadStorageBrowser({ - files: FILES, - onSelectPath: vi.fn(), - selectedPath: null, - threadId: "thr_a", - }), - ); - - act(() => { - result.current.setSearchQuery("attach"); - }); - expect(result.current.filteredFiles.map((file) => file.path)).toEqual([ - "Attachments/Screenshot.png", - ]); - - act(() => { - result.current.setSearchQuery(""); - }); - expect(result.current.filteredFiles).toHaveLength(2); - }); - - it("opens the folders above a newly selected file", () => { - const { result, rerender } = renderHook( - ({ selectedPath }: { selectedPath: string | null }) => - useThreadStorageBrowser({ - files: FILES, - onSelectPath: vi.fn(), - selectedPath, - threadId: "thr_a", - }), - { initialProps: { selectedPath: null as string | null } }, - ); - expect(result.current.expandedFolders.size).toBe(0); - - rerender({ selectedPath: "docs/notes.md" }); - expect([...result.current.expandedFolders]).toEqual(["docs"]); - expect([...result.current.foldersShowingAll]).toEqual(["docs"]); - - rerender({ selectedPath: null }); - expect(result.current.lastSelectedPath).toBe("docs/notes.md"); - - act(() => { - result.current.toggleFolder(["docs"]); - }); - expect(result.current.expandedFolders.has("docs")).toBe(false); - }); - - it("opens and closes every folder in a merged chain together", () => { - const { result } = renderHook(() => - useThreadStorageBrowser({ - files: FILES, - onSelectPath: vi.fn(), - selectedPath: null, - threadId: "thr_a", - }), - ); - act(() => { - result.current.toggleFolder(["qa", "qa/shots"]); - }); - expect([...result.current.expandedFolders]).toEqual(["qa", "qa/shots"]); - - act(() => { - result.current.toggleFolder(["qa", "qa/shots"]); - }); - expect(result.current.expandedFolders.size).toBe(0); - }); - - it("starts each thread with only the selected file's folders open", () => { - const { result, rerender } = renderHook( - ({ threadId }: { threadId: string }) => - useThreadStorageBrowser({ - files: FILES, - onSelectPath: vi.fn(), - selectedPath: null, - threadId, - }), - { initialProps: { threadId: "thr_a" } }, - ); - act(() => { - result.current.toggleFolder(["docs"]); - result.current.showAllInFolder("docs"); - }); - expect(result.current.expandedFolders.has("docs")).toBe(true); - - rerender({ threadId: "thr_b" }); - expect(result.current.expandedFolders.size).toBe(0); - expect(result.current.foldersShowingAll.size).toBe(0); - }); -}); diff --git a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts index 1896259d6e7..f5a75272d95 100644 --- a/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts +++ b/apps/app/src/components/secondary-panel/useThreadStorageBrowser.ts @@ -31,6 +31,82 @@ function ancestorsOf(path: string | null): ReadonlySet { return new Set(path ? threadStorageAncestorPaths(path) : []); } +function withPaths( + folders: ReadonlySet, + paths: readonly string[], +): ReadonlySet { + return paths.every((path) => folders.has(path)) + ? folders + : new Set([...folders, ...paths]); +} + +export interface ThreadStorageFolderState { + expandedFolders: ReadonlySet; + foldersShowingAll: ReadonlySet; + lastSelectedPath: string | null; + revealedPath: string | null; + threadId: string; +} + +export function initialThreadStorageFolderState( + threadId: string, + selectedPath: string | null, +): ThreadStorageFolderState { + return { + expandedFolders: ancestorsOf(selectedPath), + foldersShowingAll: ancestorsOf(selectedPath), + lastSelectedPath: selectedPath, + revealedPath: selectedPath, + threadId, + }; +} + +export function syncThreadStorageFolderState( + state: ThreadStorageFolderState, + { selectedPath, threadId }: { selectedPath: string | null; threadId: string }, +): ThreadStorageFolderState { + if (threadId !== state.threadId) { + return initialThreadStorageFolderState(threadId, selectedPath); + } + if (selectedPath === state.revealedPath) { + return state; + } + const ancestors = [...ancestorsOf(selectedPath)]; + return { + expandedFolders: withPaths(state.expandedFolders, ancestors), + foldersShowingAll: withPaths(state.foldersShowingAll, ancestors), + lastSelectedPath: selectedPath ?? state.lastSelectedPath, + revealedPath: selectedPath, + threadId, + }; +} + +export function toggleThreadStorageFolderChain( + expandedFolders: ReadonlySet, + chainPaths: readonly string[], +): ReadonlySet { + const key = chainPaths.at(-1); + if (key === undefined) return expandedFolders; + const next = new Set(expandedFolders); + if (expandedFolders.has(key)) { + for (const path of chainPaths) next.delete(path); + } else { + for (const path of chainPaths) next.add(path); + } + return next; +} + +export function filterThreadStorageFiles( + files: readonly WorkspaceFile[], + searchQuery: string, +): readonly WorkspaceFile[] { + const normalized = searchQuery.trim().toLowerCase(); + if (normalized.length === 0) { + return files; + } + return files.filter((file) => file.path.toLowerCase().includes(normalized)); +} + export function useThreadStorageBrowser({ files, onSelectPath, @@ -38,66 +114,47 @@ export function useThreadStorageBrowser({ threadId, }: UseThreadStorageBrowserArgs): ThreadStorageBrowserController { const [searchQuery, setSearchQuery] = useState(""); - const [expandedFolders, setExpandedFolders] = useState(() => - ancestorsOf(selectedPath), + const [folderState, setFolderState] = useState(() => + initialThreadStorageFolderState(threadId, selectedPath), ); - const [foldersShowingAll, setFoldersShowingAll] = useState(() => - ancestorsOf(selectedPath), - ); - const [revealedPath, setRevealedPath] = useState(selectedPath); - const [lastSelectedPath, setLastSelectedPath] = useState(selectedPath); - const [folderStateThreadId, setFolderStateThreadId] = useState(threadId); - if (threadId !== folderStateThreadId) { - setFolderStateThreadId(threadId); - setRevealedPath(selectedPath); - setLastSelectedPath(selectedPath); - setExpandedFolders(ancestorsOf(selectedPath)); - setFoldersShowingAll(ancestorsOf(selectedPath)); - } else if (selectedPath !== revealedPath) { - setRevealedPath(selectedPath); - if (selectedPath !== null) setLastSelectedPath(selectedPath); - const ancestors = [...ancestorsOf(selectedPath)]; - if (ancestors.some((path) => !expandedFolders.has(path))) { - setExpandedFolders(new Set([...expandedFolders, ...ancestors])); - } - if (ancestors.some((path) => !foldersShowingAll.has(path))) { - setFoldersShowingAll(new Set([...foldersShowingAll, ...ancestors])); - } + const syncedFolderState = syncThreadStorageFolderState(folderState, { + selectedPath, + threadId, + }); + if (syncedFolderState !== folderState) { + setFolderState(syncedFolderState); } const toggleFolder = useCallback((chainPaths: readonly string[]) => { - const key = chainPaths.at(-1); - if (key === undefined) return; - setExpandedFolders((current) => { - const next = new Set(current); - if (current.has(key)) { - for (const path of chainPaths) next.delete(path); - } else { - for (const path of chainPaths) next.add(path); - } - return next; + if (chainPaths.length === 0) return; + setFolderState((current) => { + const expandedFolders = toggleThreadStorageFolderChain( + current.expandedFolders, + chainPaths, + ); + return expandedFolders === current.expandedFolders + ? current + : { ...current, expandedFolders }; }); }, []); const showAllInFolder = useCallback((folderPath: string) => { - setFoldersShowingAll((current) => new Set([...current, folderPath])); + setFolderState((current) => ({ + ...current, + foldersShowingAll: new Set([...current.foldersShowingAll, folderPath]), + })); }, []); const loadedFiles = files ?? EMPTY_STORAGE_FILES; - const filteredFiles = useMemo(() => { - const normalized = searchQuery.trim().toLowerCase(); - if (normalized.length === 0) { - return loadedFiles; - } - return loadedFiles.filter((file) => - file.path.toLowerCase().includes(normalized), - ); - }, [loadedFiles, searchQuery]); + const filteredFiles = useMemo( + () => filterThreadStorageFiles(loadedFiles, searchQuery), + [loadedFiles, searchQuery], + ); return { - expandedFolders, + expandedFolders: syncedFolderState.expandedFolders, toggleFolder, - foldersShowingAll, + foldersShowingAll: syncedFolderState.foldersShowingAll, showAllInFolder, - lastSelectedPath, + lastSelectedPath: syncedFolderState.lastSelectedPath, filteredFiles, loadedFiles, searchQuery, diff --git a/apps/app/src/components/settings/SplitDimmingSetting.test.ts b/apps/app/src/components/settings/SplitDimmingSetting.test.ts new file mode 100644 index 00000000000..a35b0a6fe71 --- /dev/null +++ b/apps/app/src/components/settings/SplitDimmingSetting.test.ts @@ -0,0 +1,7 @@ +import { createStore } from "jotai"; +import { expect, it } from "vitest"; +import { dimInactiveSplitsAtom } from "@/lib/split-layout/atoms"; + +it("fades inactive splits by default", () => { + expect(createStore().get(dimInactiveSplitsAtom)).toBe(true); +}); diff --git a/apps/app/src/components/settings/SplitDimmingSetting.test.tsx b/apps/app/src/components/settings/SplitDimmingSetting.test.tsx deleted file mode 100644 index 7aaac27c81a..00000000000 --- a/apps/app/src/components/settings/SplitDimmingSetting.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment jsdom - -import { fireEvent, render, screen } from "@testing-library/react"; -import { createStore, Provider as JotaiProvider } from "jotai"; -import { beforeEach, describe, expect, it } from "vitest"; -import { - DIM_INACTIVE_SPLITS_STORAGE_KEY, - dimInactiveSplitsAtom, -} from "@/lib/split-layout/atoms"; -import { - SPLIT_DIMMING_SETTING_LABEL, - SplitDimmingSetting, -} from "./SplitDimmingSetting"; - -describe("SplitDimmingSetting", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("updates and saves the inactive split preference", () => { - const store = createStore(); - render( - - - , - ); - - expect( - screen.getByText("Fade out splits that do not have focus."), - ).not.toBeNull(); - const toggle = screen.getByRole("switch", { - name: SPLIT_DIMMING_SETTING_LABEL, - }); - expect(toggle.getAttribute("data-state")).toBe("checked"); - - fireEvent.click(toggle); - - expect(store.get(dimInactiveSplitsAtom)).toBe(false); - expect(window.localStorage.getItem(DIM_INACTIVE_SPLITS_STORAGE_KEY)).toBe( - "false", - ); - }); -}); diff --git a/apps/app/src/components/settings/SplitDimmingSetting.tsx b/apps/app/src/components/settings/SplitDimmingSetting.tsx index 1ce63ea232b..82f884caa03 100644 --- a/apps/app/src/components/settings/SplitDimmingSetting.tsx +++ b/apps/app/src/components/settings/SplitDimmingSetting.tsx @@ -3,7 +3,7 @@ import { useAtom } from "jotai"; import { SettingsWithControl } from "@/components/ui/settings-section"; import { dimInactiveSplitsAtom } from "@/lib/split-layout/atoms"; -export const SPLIT_DIMMING_SETTING_LABEL = "Fade inactive splits"; +const SPLIT_DIMMING_SETTING_LABEL = "Fade inactive splits"; export function SplitDimmingSetting() { const [dimsInactiveSplits, setDimsInactiveSplits] = useAtom( diff --git a/apps/app/src/components/settings/settings-nav.test.ts b/apps/app/src/components/settings/settings-nav.test.ts new file mode 100644 index 00000000000..2d7dba2f080 --- /dev/null +++ b/apps/app/src/components/settings/settings-nav.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + filterSettingsNavSections, + resolveSettingsRoute, +} from "./settings-nav"; + +describe("resolveSettingsRoute", () => { + it.each([ + { + pathname: "/settings/providers", + activeSection: "providers", + hasUnknownSection: false, + }, + { + pathname: "/settings/archived", + activeSection: "archived", + hasUnknownSection: false, + }, + { + pathname: "/settings/plugins", + activeSection: "plugins", + hasUnknownSection: false, + }, + ])("resolves $pathname", ({ pathname, ...expected }) => { + expect(resolveSettingsRoute({ pathname, search: "" })).toMatchObject( + expected, + ); + }); +}); + +describe("filterSettingsNavSections", () => { + it.each([ + { + name: "hides Files without a daemon, helper access or file opener", + hasDaemon: false, + accessState: "unavailable", + fileOpenerCount: 0, + showsFiles: false, + }, + { + name: "shows Files when local helper access can be enabled", + hasDaemon: false, + accessState: "permission-required", + fileOpenerCount: 0, + showsFiles: true, + }, + ] as const)("$name", ({ showsFiles, ...input }) => { + const ids = filterSettingsNavSections(input).map((section) => section.id); + expect(ids.includes("files")).toBe(showsFiles); + expect(ids).toContain("archived"); + }); +}); diff --git a/apps/app/src/components/settings/settings-nav.test.tsx b/apps/app/src/components/settings/settings-nav.test.tsx deleted file mode 100644 index 3d870ba31d7..00000000000 --- a/apps/app/src/components/settings/settings-nav.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, renderHook } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { InstalledPlugin } from "@bb/server-contract"; -import { resetPluginSlotStoreForTest } from "@/lib/plugin-slots"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { pluginListQueryKey } from "@/hooks/queries/query-keys"; -import { useSettingsNavState } from "./settings-nav"; -import { makeInstalledPlugin } from "@/test/fixtures/plugins"; - -const mocks = vi.hoisted(() => ({ - accessState: "unavailable", -})); - -vi.mock("@/hooks/useHostDaemon", () => ({ - useHostDaemon: () => ({ hasDaemon: false }), - useLocalHostDaemonAccess: () => ({ accessState: mocks.accessState }), -})); - -function wrapperFor(path: string, plugins: readonly InstalledPlugin[] = []) { - const { queryClient, wrapper: QueryWrapper } = createQueryClientTestHarness(); - queryClient.setQueryData(pluginListQueryKey(true), plugins); - return function Wrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); - }; -} - -function disabledPlugin(): InstalledPlugin { - return makeInstalledPlugin({ - id: "linear", - source: "path:/plugins/linear", - rootDir: "/plugins/linear", - enabled: false, - status: "disabled", - description: "Linear integration", - name: "Linear", - sourceDisplay: "path · /plugins/linear", - }); -} - -afterEach(() => { - cleanup(); - resetPluginSlotStoreForTest(); - mocks.accessState = "unavailable"; -}); - -describe("useSettingsNavState", () => { - it("resolves the Providers bucket from its section route", () => { - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/providers"), - }); - - expect(result.current.activeSection).toBe("providers"); - expect(result.current.hasUnknownSection).toBe(false); - }); - - it("shows the Machines section", () => { - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/machines"), - }); - - expect(result.current.sections.map((section) => section.id)).toContain( - "machines", - ); - }); - - it("shows Files when local helper access can be enabled", () => { - mocks.accessState = "permission-required"; - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/files"), - }); - - expect(result.current.sections).toContainEqual( - expect.objectContaining({ icon: "File", id: "files" }), - ); - }); - - it("resolves archived threads as a settings section", () => { - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/archived"), - }); - - expect(result.current.activeSection).toBe("archived"); - expect(result.current.sections.map((section) => section.id)).toContain( - "archived", - ); - }); - - it("recognizes installed plugins as a settings section", () => { - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings/plugins"), - }); - - expect(result.current.hasUnknownSection).toBe(false); - expect(result.current.sections.map((section) => section.id)).toContain( - "plugins", - ); - }); - - it("omits disabled plugins from individual settings entries", () => { - const { result } = renderHook(() => useSettingsNavState(), { - wrapper: wrapperFor("/settings", [disabledPlugin()]), - }); - - expect(result.current.pluginEntries).toEqual([]); - }); -}); diff --git a/apps/app/src/components/settings/settings-nav.tsx b/apps/app/src/components/settings/settings-nav.tsx index c2e00c4d917..85b0b5008cf 100644 --- a/apps/app/src/components/settings/settings-nav.tsx +++ b/apps/app/src/components/settings/settings-nav.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { matchPath, useLocation } from "react-router-dom"; import { useHostDaemon, useLocalHostDaemonAccess } from "@/hooks/useHostDaemon"; +import type { LocalHostDaemonAccessState } from "@/lib/local-host-daemon-access"; import { usePluginSlots, type PluginFileOpenerSlot } from "@/lib/plugin-slots"; import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; import { @@ -28,50 +29,44 @@ export interface SettingsNavState { sections: readonly SettingsNavSection[]; } -export function useSettingsNavSections( - fileOpeners: readonly PluginFileOpenerSlot[], -): readonly SettingsNavSection[] { - const { hasDaemon } = useHostDaemon(); - const { accessState } = useLocalHostDaemonAccess(); - - return useMemo( - () => - SETTINGS_NAV_SECTIONS.filter( - (section) => - section.id !== "files" || - hasDaemon || - accessState !== "unavailable" || - fileOpeners.length > 0, - ), - [accessState, fileOpeners.length, hasDaemon], +export function filterSettingsNavSections({ + hasDaemon, + accessState, + fileOpenerCount, +}: { + hasDaemon: boolean; + accessState: LocalHostDaemonAccessState; + fileOpenerCount: number; +}): readonly SettingsNavSection[] { + return SETTINGS_NAV_SECTIONS.filter( + (section) => + section.id !== "files" || + hasDaemon || + accessState !== "unavailable" || + fileOpenerCount > 0, ); } -export function useSettingsNavState(): SettingsNavState { - const location = useLocation(); - const { fileOpeners, settingsSections } = usePluginSlots(); - const sections = useSettingsNavSections(fileOpeners); - const pluginListQuery = usePluginList({ enabled: true }); - - const sectionMatch = matchPath( - SETTINGS_SECTION_ROUTE_PATH, - location.pathname, - ); - const pluginMatch = matchPath(SETTINGS_PLUGIN_ROUTE_PATH, location.pathname); +export function resolveSettingsRoute({ + pathname, + search, +}: { + pathname: string; + search: string; +}): Pick< + SettingsNavState, + "activePluginId" | "activeSection" | "hasUnknownSection" +> { + const sectionMatch = matchPath(SETTINGS_SECTION_ROUTE_PATH, pathname); + const pluginMatch = matchPath(SETTINGS_PLUGIN_ROUTE_PATH, pathname); const isInstalledDetail = - new URLSearchParams(location.search).get("view") === "installed"; + new URLSearchParams(search).get("view") === "installed"; const activePluginId = isInstalledDetail ? null : (pluginMatch?.params.pluginId ?? null); - const machineMatch = matchPath( - SETTINGS_MACHINE_ROUTE_PATH, - location.pathname, - ); + const machineMatch = matchPath(SETTINGS_MACHINE_ROUTE_PATH, pathname); const activeMachineId = machineMatch?.params.hostId ?? null; - const projectMatch = matchPath( - SETTINGS_PROJECT_ROUTE_PATH, - location.pathname, - ); + const projectMatch = matchPath(SETTINGS_PROJECT_ROUTE_PATH, pathname); const activeProjectId = projectMatch?.params.projectId ?? null; const sectionParam = sectionMatch?.params.section; const hasUnknownSection = @@ -88,6 +83,34 @@ export function useSettingsNavState(): SettingsNavState { : sectionParam !== undefined && isSettingsSectionId(sectionParam) ? sectionParam : "general"; + return { activePluginId, activeSection, hasUnknownSection }; +} + +export function useSettingsNavSections( + fileOpeners: readonly PluginFileOpenerSlot[], +): readonly SettingsNavSection[] { + const { hasDaemon } = useHostDaemon(); + const { accessState } = useLocalHostDaemonAccess(); + + return useMemo( + () => + filterSettingsNavSections({ + hasDaemon, + accessState, + fileOpenerCount: fileOpeners.length, + }), + [accessState, fileOpeners.length, hasDaemon], + ); +} + +export function useSettingsNavState(): SettingsNavState { + const location = useLocation(); + const { fileOpeners, settingsSections } = usePluginSlots(); + const sections = useSettingsNavSections(fileOpeners); + const pluginListQuery = usePluginList({ enabled: true }); + + const { activePluginId, activeSection, hasUnknownSection } = + resolveSettingsRoute(location); const installedPlugins = pluginListQuery.data?.plugins ?? []; const pluginEntries = buildPluginSettingsEntries({ diff --git a/apps/app/src/components/sidebar/paneContentSplitIndicator.test.ts b/apps/app/src/components/sidebar/paneContentSplitIndicator.test.ts new file mode 100644 index 00000000000..c323a4b8cf1 --- /dev/null +++ b/apps/app/src/components/sidebar/paneContentSplitIndicator.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; +import { + indicatorSplitLayoutAtom, + resolvePaneContentSplitIndicator, +} from "./paneContentSplitIndicator"; + +function content(threadId: string): PaneContent { + return { kind: "thread", projectId: "p1", threadId }; +} + +function pane(paneId: string, threadId: string): LayoutNode { + return { type: "pane", paneId, content: content(threadId) }; +} + +function twoPanes(focused: string): SplitLayout { + return { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [pane("pane-1", "t1"), pane("pane-2", "t2")], + }, + focusedPaneId: focused, + }; +} + +describe("indicatorSplitLayoutAtom", () => { + it("does not subscribe to the split layout on compact viewports", () => { + expect(indicatorSplitLayoutAtom(true, false)).toBe(splitLayoutAtom); + expect(indicatorSplitLayoutAtom(true, true)).not.toBe(splitLayoutAtom); + expect(indicatorSplitLayoutAtom(false, false)).not.toBe(splitLayoutAtom); + }); +}); + +describe("resolvePaneContentSplitIndicator", () => { + it("maps the content's pane and the focused pane onto the minimap", () => { + const indicator = resolvePaneContentSplitIndicator({ + content: content("t1"), + enabled: true, + isCompact: false, + layout: twoPanes("pane-2"), + }); + + expect(indicator.isOpenInSplit).toBe(true); + expect( + indicator.miniMap?.filter((slot) => slot.isMe).map((slot) => slot.paneId), + ).toEqual(["pane-1"]); + expect( + indicator.miniMap + ?.filter((slot) => slot.isFocused) + .map((slot) => slot.paneId), + ).toEqual(["pane-2"]); + }); + + it("shows no indicator for content that is not open in a split", () => { + expect( + resolvePaneContentSplitIndicator({ + content: content("t3"), + enabled: true, + isCompact: false, + layout: twoPanes("pane-1"), + }), + ).toEqual({ isOpenInSplit: false, miniMap: null }); + }); +}); diff --git a/apps/app/src/components/sidebar/paneContentSplitIndicator.test.tsx b/apps/app/src/components/sidebar/paneContentSplitIndicator.test.tsx deleted file mode 100644 index f1ae701cf06..00000000000 --- a/apps/app/src/components/sidebar/paneContentSplitIndicator.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook } from "@testing-library/react"; -import { createStore, Provider } from "jotai"; -import type { ReactNode } from "react"; -import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; -import { usePaneContentSplitIndicator } from "./paneContentSplitIndicator"; - -const { compactState } = vi.hoisted(() => ({ - compactState: { value: false }, -})); - -vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ - useIsCompactViewport: () => compactState.value, -})); - -function content(threadId: string): PaneContent { - return { kind: "thread", projectId: "p1", threadId }; -} - -function pane(paneId: string, threadId: string): LayoutNode { - return { type: "pane", paneId, content: content(threadId) }; -} - -function twoPanes(focused: string): SplitLayout { - return { - root: { - type: "split", - dir: "row", - sizes: [0.5, 0.5], - children: [pane("pane-1", "t1"), pane("pane-2", "t2")], - }, - focusedPaneId: focused, - }; -} - -function renderIndicator(threadId: string) { - const store = createStore(); - store.set(splitLayoutAtom, twoPanes("pane-1")); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - let renders = 0; - const target = content(threadId); - const { result } = renderHook( - () => { - renders += 1; - return usePaneContentSplitIndicator(target, true); - }, - { wrapper }, - ); - return { store, result, renderCount: () => renders }; -} - -beforeEach(() => { - compactState.value = false; -}); - -describe("usePaneContentSplitIndicator", () => { - it("does not subscribe to the split layout on compact viewports", () => { - compactState.value = true; - const { store, result, renderCount } = renderIndicator("t1"); - expect(result.current.isOpenInSplit).toBe(false); - const settled = renderCount(); - - act(() => { - store.set(splitLayoutAtom, twoPanes("pane-2")); - }); - expect(renderCount()).toBe(settled); - expect(result.current.isOpenInSplit).toBe(false); - }); - - it("follows the split layout on wide viewports", () => { - const { store, result } = renderIndicator("t1"); - expect(result.current.isOpenInSplit).toBe(true); - expect(result.current.miniMap?.find((slot) => slot.isMe)?.paneId).toBe( - "pane-1", - ); - act(() => { - store.set(splitLayoutAtom, twoPanes("pane-2")); - }); - expect(result.current.miniMap?.find((slot) => slot.isFocused)?.paneId).toBe( - "pane-2", - ); - }); -}); diff --git a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts index 6fcd78dbfae..0a78e182677 100644 --- a/apps/app/src/components/sidebar/paneContentSplitIndicator.ts +++ b/apps/app/src/components/sidebar/paneContentSplitIndicator.ts @@ -31,14 +31,19 @@ const NO_INDICATOR: PaneContentSplitIndicator = { const NULL_LAYOUT_ATOM = atom(null); +export function indicatorSplitLayoutAtom( + enabled: boolean, + isCompact: boolean, +): typeof NULL_LAYOUT_ATOM | typeof splitLayoutAtom { + return enabled && !isCompact ? splitLayoutAtom : NULL_LAYOUT_ATOM; +} + function useSplitLayoutForIndicator(enabled: boolean): { layout: SplitLayout | null; isCompact: boolean; } { const isCompact = useIsCompactViewport(); - const layout = useAtomValue( - enabled && !isCompact ? splitLayoutAtom : NULL_LAYOUT_ATOM, - ); + const layout = useAtomValue(indicatorSplitLayoutAtom(enabled, isCompact)); return { layout, isCompact }; } @@ -69,25 +74,36 @@ function buildSplitIndicator( }; } +export function resolvePaneContentSplitIndicator({ + content, + enabled, + isCompact, + layout, +}: { + content: PaneContent; + enabled: boolean; + isCompact: boolean; + layout: SplitLayout | null; +}): PaneContentSplitIndicator { + if (!enabled || layout === null || isCompact || countPanes(layout.root) < 2) { + return NO_INDICATOR; + } + const pane = findPaneByContent(layout.root, content); + if (pane === null) { + return NO_INDICATOR; + } + return buildSplitIndicator(layout, new Set([pane.paneId])); +} + export function usePaneContentSplitIndicator( content: PaneContent, enabled: boolean, ): PaneContentSplitIndicator { const { layout, isCompact } = useSplitLayoutForIndicator(enabled); - return useMemo(() => { - if ( - !enabled || - layout === null || - isCompact || - countPanes(layout.root) < 2 - ) { - return NO_INDICATOR; - } - const pane = findPaneByContent(layout.root, content); - if (pane === null) { - return NO_INDICATOR; - } - return buildSplitIndicator(layout, new Set([pane.paneId])); - }, [content, enabled, isCompact, layout]); + return useMemo( + () => + resolvePaneContentSplitIndicator({ content, enabled, isCompact, layout }), + [content, enabled, isCompact, layout], + ); } diff --git a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx b/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx deleted file mode 100644 index c93fa8034c9..00000000000 --- a/apps/app/src/components/sidebar/useThreadRowSplitDrag.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook } from "@testing-library/react"; -import { createStore, Provider } from "jotai"; -import type { ReactNode } from "react"; -import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import { countPanes, findPaneByThread, listPanes } from "@/lib/split-layout"; -import type { LayoutNode, PaneContent, SplitLayout } from "@/lib/split-layout"; -import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; -import { useThreadRowSplitDrag } from "./useThreadRowSplitDrag"; - -const { navigateSpy, compactState } = vi.hoisted(() => ({ - navigateSpy: vi.fn(), - compactState: { value: false }, -})); - -vi.mock("react-router-dom", async (importOriginal) => ({ - ...(await importOriginal()), - useNavigate: () => navigateSpy, -})); - -vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ - useIsCompactViewport: () => compactState.value, -})); - -function content(threadId: string): PaneContent { - return { kind: "thread", projectId: "p1", threadId }; -} - -function pane(paneId: string, threadId: string): LayoutNode { - return { type: "pane", paneId, content: content(threadId) }; -} - -function singlePane(): SplitLayout { - return { root: pane("pane-1", "t1"), focusedPaneId: "pane-1" }; -} - -function twoPanes(): SplitLayout { - return { - root: { - type: "split", - dir: "row", - sizes: [0.5, 0.5], - children: [pane("pane-1", "t1"), pane("pane-2", "t2")], - }, - focusedPaneId: "pane-1", - }; -} - -function eightPanes(): SplitLayout { - return { - root: { - type: "split", - dir: "row", - sizes: Array.from({ length: 8 }, () => 0.125), - children: Array.from({ length: 8 }, (_, index) => - pane(`pane-${index + 1}`, `t${index + 1}`), - ), - }, - focusedPaneId: "pane-1", - }; -} - -function renderOpenInSplit(threadId: string, layout: SplitLayout | null) { - const store = createStore(); - store.set(splitLayoutAtom, layout); - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ); - const { result } = renderHook( - () => useThreadRowSplitDrag({ projectId: "p1", threadId, title: "Thread" }), - { wrapper }, - ); - return { - store, - getOnPointerDown: () => result.current.onPointerDown, - openInSplit: () => act(() => result.current.openInSplit()), - }; -} - -describe("useThreadRowSplitDrag — openInSplit (cmd-click / context-menu entry)", () => { - beforeEach(() => { - navigateSpy.mockClear(); - compactState.value = false; - }); - - it("splits the focused pane to the right by default", () => { - const { store, openInSplit } = renderOpenInSplit("t9", singlePane()); - openInSplit(); - const layout = store.get(splitLayoutAtom); - expect(layout).not.toBeNull(); - expect(countPanes(layout!.root)).toBe(2); - const opened = findPaneByThread(layout!.root, "p1", "t9"); - expect(opened).not.toBeNull(); - expect(listPanes(layout!.root).at(-1)?.paneId).toBe(opened?.paneId); - expect(layout!.focusedPaneId).toBe(opened?.paneId); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); - }); - - it("focuses the existing pane instead of duplicating an already-open thread", () => { - const { store, openInSplit } = renderOpenInSplit("t2", twoPanes()); - openInSplit(); - const layout = store.get(splitLayoutAtom); - expect(countPanes(layout!.root)).toBe(2); - expect(layout!.focusedPaneId).toBe("pane-2"); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t2", { - replace: true, - }); - }); - - it("coerces to a replace of the focused pane at the eight-pane cap", () => { - const { store, openInSplit } = renderOpenInSplit("t9", eightPanes()); - openInSplit(); - const layout = store.get(splitLayoutAtom); - expect(countPanes(layout!.root)).toBe(8); - const opened = findPaneByThread(layout!.root, "p1", "t9"); - expect(opened?.paneId).toBe("pane-1"); - expect(findPaneByThread(layout!.root, "p1", "t1")).toBeNull(); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); - }); - - it("plain-navigates without touching the layout on compact viewports", () => { - compactState.value = true; - const seeded = singlePane(); - const { store, openInSplit } = renderOpenInSplit("t9", seeded); - openInSplit(); - expect(store.get(splitLayoutAtom)).toBe(seeded); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); - }); - - it("plain-navigates when there is no layout yet", () => { - const { store, openInSplit } = renderOpenInSplit("t9", null); - openInSplit(); - expect(store.get(splitLayoutAtom)).toBeNull(); - expect(navigateSpy).toHaveBeenCalledWith("/projects/p1/threads/t9"); - }); -}); diff --git a/apps/app/src/components/thread/ThreadTitleMentions.resources.test.ts b/apps/app/src/components/thread/ThreadTitleMentions.resources.test.ts index fb1eb995288..0b79e63a3a1 100644 --- a/apps/app/src/components/thread/ThreadTitleMentions.resources.test.ts +++ b/apps/app/src/components/thread/ThreadTitleMentions.resources.test.ts @@ -3,6 +3,7 @@ import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { buildThreadTitleMentionResources, EMPTY_TITLE_MENTION_RESOURCES, + threadTitleTextSegments, type ThreadTitleMentionNavigationSource, } from "./ThreadTitleMentions"; @@ -95,3 +96,126 @@ describe("buildThreadTitleMentionResources", () => { ).toBe(EMPTY_TITLE_MENTION_RESOURCES); }); }); + +describe("threadTitleTextSegments", () => { + const rawId = "thr_dcwivn5n8w"; + + function resourcesWithThread(title: string) { + return { + ...EMPTY_TITLE_MENTION_RESOURCES, + threadById: new Map([ + [ + rawId, + makeThreadListEntry({ + id: rawId, + projectId: "proj_target", + title, + titleFallback: title, + }), + ], + ]), + }; + } + + it("renders serialized mentions in the thread title as pills", () => { + const segments = threadTitleTextSegments( + "Review @docs/foo.test.ts with @thread:thr_worker", + EMPTY_TITLE_MENTION_RESOURCES, + ); + + expect(segments.map((segment) => segment.text)).toEqual([ + "Review ", + "foo.test.ts", + " with ", + "thr_worker", + ]); + expect(segments[1]?.resource).toMatchObject({ + kind: "path", + path: "docs/foo.test.ts", + }); + expect(segments[3]?.resource).toMatchObject({ + kind: "thread", + threadId: "thr_worker", + }); + }); + + it("renders a raw thread id in the title as a mention pill", () => { + const segments = threadTitleTextSegments( + `Continue from ${rawId} docs/foo.ts`, + resourcesWithThread("Raw title target"), + ); + + expect(segments.map((segment) => segment.text)).toEqual([ + "Continue from ", + "Raw title target", + " docs/foo.ts", + ]); + expect(segments[1]?.resource).toMatchObject({ + kind: "thread", + threadId: rawId, + }); + }); + + it.each([ + ["straight closing quote", `Review "${rawId}."`], + ["curly closing quote", `Review “${rawId}.”`], + ])( + "renders a sentence-final raw id before a %s in a title", + (_label, title) => { + const segments = threadTitleTextSegments( + title, + resourcesWithThread("Quoted title target"), + ); + + expect(segments.find((segment) => segment.resource !== null)?.text).toBe( + "Quoted title target", + ); + }, + ); + + it("leaves raw-id path, extension, and overlong continuations literal in titles", () => { + const title = [ + `${rawId}.md`, + `${rawId}/path`, + `${rawId}2`, + `/tmp/${rawId}`, + `docs/${rawId}`, + `C:\\tmp\\${rawId}`, + `docs\\${rawId}`, + `${rawId}\\logs`, + ].join(" "); + + expect( + threadTitleTextSegments(title, resourcesWithThread("Should not render")), + ).toEqual([ + { + resource: null, + serializedText: null, + text: title, + unresolvedThreadId: null, + }, + ]); + }); + + it("leaves an unresolvable raw thread id literal and requests it", () => { + expect( + threadTitleTextSegments( + "Unknown thr_2222222222", + EMPTY_TITLE_MENTION_RESOURCES, + ), + ).toEqual([ + { + resource: null, + serializedText: null, + text: "Unknown ", + unresolvedThreadId: null, + }, + { + resource: null, + serializedText: "thr_2222222222", + text: "thr_2222222222", + unresolvedThreadId: "thr_2222222222", + }, + ]); + }); +}); diff --git a/apps/app/src/components/thread/ThreadTitleMentions.tsx b/apps/app/src/components/thread/ThreadTitleMentions.tsx index 45f14ac6f3e..f93405a67d8 100644 --- a/apps/app/src/components/thread/ThreadTitleMentions.tsx +++ b/apps/app/src/components/thread/ThreadTitleMentions.tsx @@ -588,7 +588,7 @@ function serializedThreadMentionId(token: string): string | null { return token.startsWith(prefix) ? token.slice(prefix.length) : null; } -function threadTitleTextSegments( +export function threadTitleTextSegments( title: string, resources: ThreadTitleMentionResources, ): ThreadTitleTextSegment[] { diff --git a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.ts b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.ts new file mode 100644 index 00000000000..e03d85dbdad --- /dev/null +++ b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PromptDraftAttachment } from "@bb/client-core"; +import { BbHttpError } from "@bb/sdk/browser"; +import { + IDLE_DRAFT_ATTACHMENT_OPERATION, + beginDraftAttachmentOperation, + finishDraftAttachmentOperation, + setDraftAttachmentOperationError, + uploadDraftAttachments, + viewDraftAttachmentOperation, +} from "./useComposerAttachmentUploads"; + +const uploaded: PromptDraftAttachment = { + type: "localFile", + path: "uploads/old.txt", + name: "old.txt", + sizeBytes: 3, +}; + +function pending(name: string) { + return { id: name, file: new File([name], name) }; +} + +describe("draft attachment operation", () => { + it("keeps attaching until every concurrent batch for the target settles", () => { + const first = beginDraftAttachmentOperation( + IDLE_DRAFT_ATTACHMENT_OPERATION, + "bottom", + ); + const both = beginDraftAttachmentOperation(first, "bottom"); + const oneLeft = finishDraftAttachmentOperation(both, "bottom", null, "bottom"); + + expect( + viewDraftAttachmentOperation(oneLeft, "bottom").isAttachingFiles, + ).toBe(true); + expect( + viewDraftAttachmentOperation( + finishDraftAttachmentOperation(oneLeft, "bottom", null, "bottom"), + "bottom", + ).isAttachingFiles, + ).toBe(false); + }); + + it("hides another target's pending upload and error", () => { + const failed = finishDraftAttachmentOperation( + beginDraftAttachmentOperation( + beginDraftAttachmentOperation( + IDLE_DRAFT_ATTACHMENT_OPERATION, + "edit-1", + ), + "edit-1", + ), + "edit-1", + "Failed to attach: old.txt", + "edit-1", + ); + + expect(viewDraftAttachmentOperation(failed, "edit-1")).toEqual({ + attachmentError: "Failed to attach: old.txt", + isAttachingFiles: true, + }); + expect(viewDraftAttachmentOperation(failed, "edit-2")).toEqual({ + attachmentError: null, + isAttachingFiles: false, + }); + expect(viewDraftAttachmentOperation(failed, null)).toEqual({ + attachmentError: null, + isAttachingFiles: false, + }); + }); + + it("ignores a batch that settles after the target moved on", () => { + const moved = beginDraftAttachmentOperation( + beginDraftAttachmentOperation(IDLE_DRAFT_ATTACHMENT_OPERATION, "edit-1"), + "edit-2", + ); + + expect( + finishDraftAttachmentOperation( + moved, + "edit-1", + "Failed to attach: old.txt", + "edit-2", + ), + ).toBe(moved); + }); + + it("drops the failure of a batch whose target was dismissed before it settled", () => { + const busy = beginDraftAttachmentOperation( + IDLE_DRAFT_ATTACHMENT_OPERATION, + "edit-1", + ); + + expect( + viewDraftAttachmentOperation( + finishDraftAttachmentOperation( + busy, + "edit-1", + "Failed to attach: old.txt", + null, + ), + "edit-1", + ), + ).toEqual({ attachmentError: null, isAttachingFiles: false }); + }); + + it("starts a new target's error without inheriting the old target's pending count", () => { + const busy = beginDraftAttachmentOperation( + IDLE_DRAFT_ATTACHMENT_OPERATION, + "edit-1", + ); + + expect( + viewDraftAttachmentOperation( + setDraftAttachmentOperationError(busy, "edit-2", "Too large"), + "edit-2", + ), + ).toEqual({ attachmentError: "Too large", isAttachingFiles: false }); + }); +}); + +describe("uploadDraftAttachments", () => { + it("does not add a late upload to a target that replaced the original", async () => { + const addFirst = vi.fn(); + const addSecond = vi.fn(); + let currentTarget = { key: "edit-1", addAttachment: addFirst }; + const settled: string[] = []; + + const result = await uploadDraftAttachments({ + uploads: [pending("old.txt")], + targetKey: "edit-1", + upload: async () => { + currentTarget = { key: "edit-2", addAttachment: addSecond }; + return uploaded; + }, + getCurrentTarget: () => currentTarget, + onUploadSettled: (upload) => settled.push(upload.id), + }); + + expect(result).toEqual({ added: [], failureMessage: null }); + expect(addFirst).not.toHaveBeenCalled(); + expect(addSecond).not.toHaveBeenCalled(); + expect(settled).toEqual(["old.txt"]); + }); + + it("adds uploads to the target that is still current", async () => { + const addAttachment = vi.fn(); + + const result = await uploadDraftAttachments({ + uploads: [pending("old.txt")], + targetKey: "bottom", + upload: async () => uploaded, + getCurrentTarget: () => ({ key: "bottom", addAttachment }), + onUploadSettled: vi.fn(), + }); + + expect(result.added).toEqual([uploaded]); + expect(addAttachment).toHaveBeenCalledWith(uploaded); + }); + + it("shows the server's reason when it refuses an upload", async () => { + const message = + "HEIC images are not supported. Convert the image to JPEG or PNG before attaching it."; + const upload = vi + .fn<(file: File) => Promise>() + .mockRejectedValueOnce( + new BbHttpError({ + body: { code: "invalid_request", message }, + code: "invalid_request", + message, + status: 400, + }), + ) + .mockRejectedValueOnce(new TypeError("Failed to fetch")); + + const result = await uploadDraftAttachments({ + uploads: [pending("IMG_0001.heic"), pending("shot.png")], + targetKey: "bottom", + upload, + getCurrentTarget: () => ({ key: "bottom", addAttachment: vi.fn() }), + onUploadSettled: vi.fn(), + }); + + expect(result.failureMessage).toBe( + `Failed to attach IMG_0001.heic, shot.png: ${message}`, + ); + }); + + it("names failed files without a reason when the failure is not a server refusal", async () => { + const result = await uploadDraftAttachments({ + uploads: [pending("inline.txt")], + targetKey: "bottom", + upload: async () => { + throw new Error("inline failed"); + }, + getCurrentTarget: () => ({ key: "bottom", addAttachment: vi.fn() }), + onUploadSettled: vi.fn(), + }); + + expect(result.failureMessage).toBe("Failed to attach: inline.txt"); + }); + +}); diff --git a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx deleted file mode 100644 index a15816918e1..00000000000 --- a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx +++ /dev/null @@ -1,275 +0,0 @@ -// @vitest-environment jsdom - -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { InlineComposerDraftSession } from "./useActiveComposerDraft"; -import type { PromptDraftAttachment } from "@bb/client-core"; -import { BbHttpError } from "@bb/sdk/browser"; -import { createDeferredPromise } from "@bb/test-helpers"; -import { - useComposerAttachmentUploads, - useDraftAttachmentUploads, -} from "./useComposerAttachmentUploads"; - -const mocks = vi.hoisted(() => ({ - upload: vi.fn(), -})); - -vi.mock("@/hooks/mutations/project-mutations", () => ({ - useUploadPromptAttachment: () => ({ mutateAsync: mocks.upload }), -})); - -function makeInlineSession( - editSessionId: number, - setDraft = vi.fn(), -): InlineComposerDraftSession { - return { editSessionId, setDraft }; -} - -describe("useComposerAttachmentUploads", () => { - beforeEach(() => { - mocks.upload.mockReset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("keeps bottom and queued attachment operations independent", async () => { - const bottomUpload = createDeferredPromise(); - const inlineUpload = createDeferredPromise(); - mocks.upload - .mockReturnValueOnce(bottomUpload.promise) - .mockReturnValueOnce(inlineUpload.promise); - const inline = makeInlineSession(1); - const inlineRef = { current: inline }; - const { result } = renderHook(() => - useComposerAttachmentUploads({ - projectId: "proj_1", - addDraftAttachment: vi.fn(), - inlineEditSessionId: inline.editSessionId, - inlineSessionRef: inlineRef, - }), - ); - - let bottomPromise!: Promise; - act(() => { - bottomPromise = result.current.handleAttachBottomFiles([ - new File(["bottom"], "bottom.txt"), - ]); - }); - expect(result.current.isAttachingBottomFiles).toBe(true); - expect(result.current.isAttachingInlineFiles).toBe(false); - expect(result.current.bottomPendingUploads.map((upload) => upload.file.name)).toEqual(["bottom.txt"]); - expect(result.current.inlinePendingUploads).toEqual([]); - - let inlinePromise!: Promise; - act(() => { - inlinePromise = result.current.handleAttachInlineFiles([ - new File(["inline"], "inline.txt"), - ]); - }); - expect(result.current.isAttachingBottomFiles).toBe(true); - expect(result.current.isAttachingInlineFiles).toBe(true); - expect(result.current.inlinePendingUploads.map((upload) => upload.file.name)).toEqual(["inline.txt"]); - - await act(async () => { - inlineUpload.reject(new Error("inline failed")); - await inlinePromise; - }); - expect(result.current.inlineAttachmentError).toBe( - "Failed to attach: inline.txt", - ); - expect(result.current.bottomAttachmentError).toBeNull(); - expect(result.current.isAttachingBottomFiles).toBe(true); - expect(result.current.inlinePendingUploads).toEqual([]); - expect(result.current.bottomPendingUploads).toHaveLength(1); - - await act(async () => { - bottomUpload.reject(new Error("bottom failed")); - await bottomPromise; - }); - expect(result.current.bottomAttachmentError).toBe( - "Failed to attach: bottom.txt", - ); - expect(result.current.inlineAttachmentError).toBe( - "Failed to attach: inline.txt", - ); - }); - - it("does not leak a dismissed upload into a later queued edit", async () => { - const oldUpload = createDeferredPromise(); - mocks.upload.mockReturnValueOnce(oldUpload.promise); - const setDraft = vi.fn(); - const firstEdit = makeInlineSession(1, setDraft); - const inlineRef: { current: InlineComposerDraftSession | null } = { - current: firstEdit, - }; - const { result, rerender } = renderHook( - ({ inline }: { inline: InlineComposerDraftSession | null }) => - useComposerAttachmentUploads({ - projectId: "proj_1", - addDraftAttachment: vi.fn(), - inlineEditSessionId: inline?.editSessionId ?? null, - inlineSessionRef: inlineRef, - }), - { - initialProps: { - inline: firstEdit as InlineComposerDraftSession | null, - }, - }, - ); - - let uploadPromise!: Promise; - act(() => { - uploadPromise = result.current.handleAttachInlineFiles([ - new File(["old"], "old.txt"), - ]); - }); - expect(result.current.isAttachingInlineFiles).toBe(true); - expect(result.current.inlinePendingUploads).toHaveLength(1); - - inlineRef.current = null; - rerender({ inline: null }); - expect(result.current.isAttachingInlineFiles).toBe(false); - expect(result.current.inlineAttachmentError).toBeNull(); - expect(result.current.inlinePendingUploads).toEqual([]); - - const secondEdit = makeInlineSession(2, setDraft); - inlineRef.current = secondEdit; - rerender({ inline: secondEdit }); - await act(async () => { - oldUpload.reject(new Error("old failed")); - await uploadPromise; - }); - - expect(result.current.isAttachingInlineFiles).toBe(false); - expect(result.current.inlineAttachmentError).toBeNull(); - expect(setDraft).not.toHaveBeenCalled(); - }); - - it("shows the server's reason when it refuses an upload", async () => { - const message = - "HEIC images are not supported. Convert the image to JPEG or PNG before attaching it."; - mocks.upload - .mockRejectedValueOnce( - new BbHttpError({ - body: { code: "invalid_request", message }, - code: "invalid_request", - message, - status: 400, - }), - ) - .mockRejectedValueOnce(new TypeError("Failed to fetch")); - const { result } = renderHook(() => - useDraftAttachmentUploads({ - projectId: "proj_1", - target: { key: "bottom", addAttachment: vi.fn() }, - }), - ); - - await act(async () => { - await result.current.handleAttachFiles([ - new File(["heic"], "IMG_0001.heic", { type: "image/heic" }), - new File(["png"], "shot.png", { type: "image/png" }), - ]); - }); - - expect(result.current.attachmentError).toBe( - `Failed to attach IMG_0001.heic, shot.png: ${message}`, - ); - }); - - it("settles concurrent previews without the secure-context randomUUID API", async () => { - vi.stubGlobal("crypto", { - getRandomValues: crypto.getRandomValues.bind(crypto), - }); - const first = createDeferredPromise(); - const concurrent = createDeferredPromise(); - const second = createDeferredPromise(); - mocks.upload.mockReturnValueOnce(first.promise).mockReturnValueOnce(concurrent.promise).mockReturnValueOnce(second.promise); - const addAttachment = vi.fn(); - const { result } = renderHook(() => useDraftAttachmentUploads({ - projectId: "proj_1", - target: { key: "bottom", addAttachment }, - })); - const file = new File(["image"], "same-name.png", { type: "image/png" }); - let batch!: Promise; - let other!: Promise; - act(() => { - batch = result.current.handleAttachFiles([file, file]); - other = result.current.handleAttachFiles([file]); - }); - expect(new Set(result.current.pendingUploads.map((upload) => upload.id)).size).toBe(3); - expect(result.current.isAttachingFiles).toBe(true); - await act(async () => { - first.resolve({ type: "localImage", name: file.name, path: "uploaded.png", sizeBytes: 5 }); - await first.promise; - }); - expect(addAttachment).toHaveBeenCalledTimes(1); - expect(result.current.pendingUploads).toHaveLength(2); - await act(async () => { - concurrent.reject(new Error("Failed")); - await other; - }); - expect(result.current.pendingUploads).toHaveLength(1); - await act(async () => { - second.reject(new Error("Failed")); - await batch; - }); - expect(result.current.pendingUploads).toEqual([]); - expect(result.current.isAttachingFiles).toBe(false); - }); - - it("does not leak a dismissed upload into a later independent draft", async () => { - const oldUpload = createDeferredPromise(); - mocks.upload.mockReturnValueOnce(oldUpload.promise); - const addFirstAttachment = vi.fn(); - const addSecondAttachment = vi.fn(); - const { result, rerender } = renderHook( - ({ target }) => - useDraftAttachmentUploads({ - projectId: "proj_1", - target, - }), - { - initialProps: { - target: { - key: "edit-1", - addAttachment: addFirstAttachment, - } as { - key: string; - addAttachment: (attachment: PromptDraftAttachment) => void; - } | null, - }, - }, - ); - - let uploadPromise!: Promise; - act(() => { - uploadPromise = result.current.handleAttachFiles([ - new File(["old"], "old.txt"), - ]); - }); - expect(result.current.isAttachingFiles).toBe(true); - - rerender({ target: null }); - rerender({ - target: { key: "edit-2", addAttachment: addSecondAttachment }, - }); - await act(async () => { - oldUpload.resolve({ - type: "localFile", - path: "uploads/old.txt", - name: "old.txt", - sizeBytes: 3, - }); - await uploadPromise; - }); - - expect(addFirstAttachment).not.toHaveBeenCalled(); - expect(addSecondAttachment).not.toHaveBeenCalled(); - expect(result.current.attachmentError).toBeNull(); - expect(result.current.isAttachingFiles).toBe(false); - }); -}); diff --git a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts index 196a9c3973e..14c2a5d7d92 100644 --- a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts +++ b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts @@ -47,12 +47,18 @@ interface UseDraftAttachmentUploadsResult { pendingUploads: readonly PendingAttachmentUpload[]; } -interface DraftAttachmentOperationState { +export interface DraftAttachmentOperationState { error: string | null; pendingCount: number; targetKey: string | null; } +export const IDLE_DRAFT_ATTACHMENT_OPERATION: DraftAttachmentOperationState = { + error: null, + pendingCount: 0, + targetKey: null, +}; + function uploadRejectionReason(error: unknown): string | null { return error instanceof BbHttpError ? getMutationErrorMessage({ error, fallbackMessage: "Request failed" }) @@ -69,6 +75,101 @@ function attachFailureMessage( : `Failed to attach ${names}: ${reason}`; } +export function beginDraftAttachmentOperation( + current: DraftAttachmentOperationState, + targetKey: string, +): DraftAttachmentOperationState { + return { + error: null, + pendingCount: + current.targetKey === targetKey ? current.pendingCount + 1 : 1, + targetKey, + }; +} + +export function setDraftAttachmentOperationError( + current: DraftAttachmentOperationState, + targetKey: string | null, + error: string | null, +): DraftAttachmentOperationState { + return { + error, + pendingCount: current.targetKey === targetKey ? current.pendingCount : 0, + targetKey, + }; +} + +export function finishDraftAttachmentOperation( + current: DraftAttachmentOperationState, + targetKey: string, + failureMessage: string | null, + currentTargetKey: string | null, +): DraftAttachmentOperationState { + if (current.targetKey !== targetKey) return current; + return { + error: + failureMessage !== null && currentTargetKey === targetKey + ? failureMessage + : current.error, + pendingCount: Math.max(0, current.pendingCount - 1), + targetKey, + }; +} + +export function viewDraftAttachmentOperation( + operation: DraftAttachmentOperationState, + targetKey: string | null, +): { attachmentError: string | null; isAttachingFiles: boolean } { + const isCurrentOperation = operation.targetKey === targetKey; + return { + attachmentError: isCurrentOperation ? operation.error : null, + isAttachingFiles: isCurrentOperation && operation.pendingCount > 0, + }; +} + +export async function uploadDraftAttachments({ + uploads, + targetKey, + upload, + getCurrentTarget, + onUploadSettled, +}: { + uploads: readonly PendingAttachmentUpload[]; + targetKey: string; + upload: (file: File) => Promise; + getCurrentTarget: () => DraftAttachmentUploadTarget | null; + onUploadSettled: (upload: PendingAttachmentUpload) => void; +}): Promise<{ + added: PromptDraftAttachment[]; + failureMessage: string | null; +}> { + const added: PromptDraftAttachment[] = []; + const failedFiles: string[] = []; + let rejectionReason: string | null = null; + for (const pendingUpload of uploads) { + try { + const uploaded = await upload(pendingUpload.file); + const currentTarget = getCurrentTarget(); + if (currentTarget?.key === targetKey) { + currentTarget.addAttachment(uploaded); + added.push(uploaded); + } + } catch (error) { + failedFiles.push(pendingUpload.file.name); + rejectionReason ??= uploadRejectionReason(error); + } finally { + onUploadSettled(pendingUpload); + } + } + return { + added, + failureMessage: + failedFiles.length > 0 + ? attachFailureMessage(failedFiles, rejectionReason) + : null, + }; +} + export function useDraftAttachmentUploads({ projectId, target, @@ -76,13 +177,10 @@ export function useDraftAttachmentUploads({ const uploadPromptAttachment = useUploadPromptAttachment(); const targetRef = useRef(target); targetRef.current = target; - const [operation, setOperation] = useState({ - error: null, - pendingCount: 0, - targetKey: null, - }); + const [operation, setOperation] = useState( + IDLE_DRAFT_ATTACHMENT_OPERATION, + ); const targetKey = target?.key ?? null; - const isCurrentOperation = operation.targetKey === targetKey; const { pendingUploads, startUploads, finishUploads } = usePendingAttachmentUploads( targetKey === null ? null : `${projectId}\0${targetKey}`, @@ -90,12 +188,9 @@ export function useDraftAttachmentUploads({ const setAttachmentError = useCallback( (error: string | null) => { - setOperation((current) => ({ - error, - pendingCount: - current.targetKey === targetKey ? current.pendingCount : 0, - targetKey, - })); + setOperation((current) => + setDraftAttachmentOperationError(current, targetKey, error), + ); }, [targetKey], ); @@ -104,62 +199,40 @@ export function useDraftAttachmentUploads({ const activeTarget = targetRef.current; if (!activeTarget || files.length === 0) return []; const capturedTargetKey = activeTarget.key; - setOperation((current) => ({ - error: null, - pendingCount: - current.targetKey === capturedTargetKey - ? current.pendingCount + 1 - : 1, - targetKey: capturedTargetKey, - })); + setOperation((current) => + beginDraftAttachmentOperation(current, capturedTargetKey), + ); const uploads = startUploads(files); - const added: PromptDraftAttachment[] = []; - const failedFiles: string[] = []; - let rejectionReason: string | null = null; + let failureMessage: string | null = null; try { - for (const upload of uploads) { - try { - const uploaded = await uploadPromptAttachment.mutateAsync({ - projectId, - file: upload.file, - }); - const currentTarget = targetRef.current; - if (currentTarget?.key === capturedTargetKey) { - currentTarget.addAttachment(uploaded); - added.push(uploaded); - } - } catch (error) { - failedFiles.push(upload.file.name); - rejectionReason ??= uploadRejectionReason(error); - } finally { - finishUploads([upload]); - } - } + const result = await uploadDraftAttachments({ + uploads, + targetKey: capturedTargetKey, + upload: (file) => + uploadPromptAttachment.mutateAsync({ projectId, file }), + getCurrentTarget: () => targetRef.current, + onUploadSettled: (upload) => finishUploads([upload]), + }); + failureMessage = result.failureMessage; + return result.added; } finally { setOperation((current) => - current.targetKey === capturedTargetKey - ? { - error: - failedFiles.length > 0 && - targetRef.current?.key === capturedTargetKey - ? attachFailureMessage(failedFiles, rejectionReason) - : current.error, - pendingCount: Math.max(0, current.pendingCount - 1), - targetKey: capturedTargetKey, - } - : current, + finishDraftAttachmentOperation( + current, + capturedTargetKey, + failureMessage, + targetRef.current?.key ?? null, + ), ); } - return added; }, [projectId, uploadPromptAttachment, startUploads, finishUploads], ); return { - attachmentError: isCurrentOperation ? operation.error : null, + ...viewDraftAttachmentOperation(operation, targetKey), setAttachmentError, handleAttachFiles, - isAttachingFiles: isCurrentOperation && operation.pendingCount > 0, pendingUploads, }; } diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.tsx b/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.ts similarity index 74% rename from apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.tsx rename to apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.ts index 3b789eaf37f..5fdd4df6b69 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.test.ts @@ -1,10 +1,7 @@ -// @vitest-environment jsdom - -import { renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { BbHttpError } from "@bb/sdk/browser"; import { createDeferredPromise } from "@bb/test-helpers"; -import { useQueuedMessageEditHold } from "./useQueuedMessageEditHold"; +import { startQueuedMessageEditHold } from "./useQueuedMessageEditHold"; const mocks = vi.hoisted(() => ({ holdForEdit: vi.fn(), @@ -28,13 +25,9 @@ vi.mock("@/lib/mutation-errors", () => ({ showMutationErrorToast: mocks.showMutationErrorToast, })); -interface HoldProps { - queuedMessageId: string | null; -} - const target = { queuedMessageId: "qmsg_1", threadId: "thr_1" }; -describe("useQueuedMessageEditHold", () => { +describe("startQueuedMessageEditHold", () => { beforeEach(() => { mocks.holdForEdit.mockReset(); mocks.releaseEditHold.mockReset().mockResolvedValue({ ok: true }); @@ -45,25 +38,21 @@ describe("useQueuedMessageEditHold", () => { const hold = createDeferredPromise<{ leaseMs: number }>(); mocks.holdForEdit.mockReturnValue(hold.promise); const onRejected = vi.fn(); - const { rerender } = renderHook( - ({ queuedMessageId }: HoldProps) => - useQueuedMessageEditHold({ - queuedMessageId, - threadId: "thr_1", - onRejected, - }), - { initialProps: { queuedMessageId: "qmsg_1" } as HoldProps }, - ); - await waitFor(() => { + const stop = startQueuedMessageEditHold({ + target, + requestChain: { current: Promise.resolve() }, + onRejected, + }); + await vi.waitFor(() => { expect(mocks.holdForEdit).toHaveBeenCalledWith(target); }); - rerender({ queuedMessageId: null }); + stop(); await Promise.resolve(); expect(mocks.releaseEditHold).not.toHaveBeenCalled(); hold.resolve({ leaseMs: 120_000 }); - await waitFor(() => { + await vi.waitFor(() => { expect(mocks.releaseEditHold).toHaveBeenCalledWith(target); }); expect(mocks.holdForEdit).toHaveBeenCalledTimes(1); @@ -83,11 +72,16 @@ describe("useQueuedMessageEditHold", () => { }), ); const onRejected = vi.fn(); - renderHook(() => useQueuedMessageEditHold({ ...target, onRejected })); + const stop = startQueuedMessageEditHold({ + target, + requestChain: { current: Promise.resolve() }, + onRejected, + }); - await waitFor(() => { + await vi.waitFor(() => { expect(onRejected).toHaveBeenCalledTimes(1); }); expect(mocks.showMutationErrorToast).toHaveBeenCalledTimes(1); + stop(); }); }); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.ts index cf6aa607f49..270bba3e7d8 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.ts +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageEditHold.ts @@ -11,6 +11,65 @@ interface UseQueuedMessageEditHoldArgs { onRejected: () => void; } +interface QueuedMessageEditHoldTarget { + queuedMessageId: string; + threadId: string; +} + +export function startQueuedMessageEditHold({ + target, + requestChain, + onRejected, +}: { + target: QueuedMessageEditHoldTarget; + requestChain: { current: Promise }; + onRejected: () => void; +}): () => void { + let active = true; + let renewTimer: ReturnType | null = null; + const enqueue = (request: () => Promise): Promise => { + const next = requestChain.current.then(request); + requestChain.current = next.catch(() => undefined); + return next; + }; + const hold = () => { + renewTimer = null; + enqueue(async () => { + if (!active) return; + const { leaseMs } = + await sdk.threads.queuedMessages.experimental_holdForEdit(target); + if (active) { + renewTimer = setTimeout(hold, leaseMs / EDIT_HOLD_RENEWALS_PER_LEASE); + } + }).catch((error: unknown) => { + if (!active) return; + if ( + error instanceof BbHttpError && + (error.status === 404 || error.status === 409) + ) { + showMutationErrorToast({ + error, + fallbackMessage: "Failed to edit queued message", + lifecycleOperation: "update_queued_message", + }); + onRejected(); + return; + } + renewTimer = setTimeout(hold, EDIT_HOLD_RETRY_MS); + }); + }; + hold(); + return () => { + active = false; + if (renewTimer !== null) { + clearTimeout(renewTimer); + } + enqueue(async () => { + await sdk.threads.queuedMessages.experimental_releaseEditHold(target); + }).catch(() => undefined); + }; +} + export function useQueuedMessageEditHold({ queuedMessageId, threadId, @@ -22,49 +81,10 @@ export function useQueuedMessageEditHold({ if (queuedMessageId === null || threadId === null) { return; } - const target = { queuedMessageId, threadId }; - let active = true; - let renewTimer: ReturnType | null = null; - const enqueue = (request: () => Promise): Promise => { - const next = requestChainRef.current.then(request); - requestChainRef.current = next.catch(() => undefined); - return next; - }; - const hold = () => { - renewTimer = null; - enqueue(async () => { - if (!active) return; - const { leaseMs } = - await sdk.threads.queuedMessages.experimental_holdForEdit(target); - if (active) { - renewTimer = setTimeout(hold, leaseMs / EDIT_HOLD_RENEWALS_PER_LEASE); - } - }).catch((error: unknown) => { - if (!active) return; - if ( - error instanceof BbHttpError && - (error.status === 404 || error.status === 409) - ) { - showMutationErrorToast({ - error, - fallbackMessage: "Failed to edit queued message", - lifecycleOperation: "update_queued_message", - }); - onRejected(); - return; - } - renewTimer = setTimeout(hold, EDIT_HOLD_RETRY_MS); - }); - }; - hold(); - return () => { - active = false; - if (renewTimer !== null) { - clearTimeout(renewTimer); - } - enqueue(async () => { - await sdk.threads.queuedMessages.experimental_releaseEditHold(target); - }).catch(() => undefined); - }; + return startQueuedMessageEditHold({ + target: { queuedMessageId, threadId }, + requestChain: requestChainRef, + onRejected, + }); }, [onRejected, queuedMessageId, threadId]); } diff --git a/apps/app/src/components/thread/terminal/TerminalLinkOpenDialog.test.tsx b/apps/app/src/components/thread/terminal/TerminalLinkOpenDialog.test.tsx deleted file mode 100644 index b1d5de4e465..00000000000 --- a/apps/app/src/components/thread/terminal/TerminalLinkOpenDialog.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TerminalLinkOpenDialog } from "./TerminalLinkOpenDialog"; - -const TARGET = { - source: "osc8" as const, - uri: "https://example.com/hidden-target?token=visible", -}; - -afterEach(cleanup); - -describe("TerminalLinkOpenDialog", () => { - it("discloses the exact target and cancels without opening it", () => { - const onConfirm = vi.fn(); - const onOpenChange = vi.fn(); - - render( - , - ); - - expect(screen.getByRole("dialog")).toBeTruthy(); - expect(screen.getByLabelText("Link target").textContent).toBe(TARGET.uri); - - fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onConfirm).not.toHaveBeenCalled(); - }); - - it("confirms the exact disclosed target", () => { - const onConfirm = vi.fn(); - - render( - , - ); - - fireEvent.click(screen.getByRole("button", { name: "Open" })); - - expect(onConfirm).toHaveBeenCalledOnce(); - expect(onConfirm).toHaveBeenCalledWith(TARGET); - }); -}); diff --git a/apps/app/src/components/thread/terminal/ThreadTerminalView.context-menu.test.tsx b/apps/app/src/components/thread/terminal/ThreadTerminalView.context-menu.test.tsx deleted file mode 100644 index 607022e941f..00000000000 --- a/apps/app/src/components/thread/terminal/ThreadTerminalView.context-menu.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { captureTerminalContextMenuState } from "./ThreadTerminalView"; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -describe("terminal context menu", () => { - it("captures the logical selection before xterm replaces it on macOS", async () => { - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ - measureText: (text: string) => ({ width: text.length * 9 }), - } as unknown as CanvasRenderingContext2D); - vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation( - function offsetWidth(this: HTMLElement) { - return this.classList.contains("xterm-char-measure-element") - ? 288 - : 108; - }, - ); - vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( - function offsetHeight(this: HTMLElement) { - return this.classList.contains("xterm-char-measure-element") ? 18 : 90; - }, - ); - const { Terminal } = await import("@xterm/xterm"); - const terminal = new Terminal({ - cols: 12, - rightClickSelectsWord: true, - rows: 5, - }); - let capturedSelection = ""; - - render( -
{ - capturedSelection = captureTerminalContextMenuState({ - link: null, - terminal, - }).selectionText; - }} - > -
-
, - ); - terminal.open(screen.getByTestId("terminal-host")); - await new Promise((resolve) => { - terminal.write("alpha bravo charlie", resolve); - }); - terminal.select(0, 0, 5); - - const terminalScreen = screen - .getByTestId("terminal-host") - .querySelector(".xterm-screen"); - if (terminalScreen === null) { - throw new Error("Expected xterm screen"); - } - terminalScreen.style.padding = "0"; - vi.spyOn(terminalScreen, "getBoundingClientRect").mockReturnValue( - new DOMRect(0, 0, 108, 90), - ); - fireEvent.contextMenu(terminalScreen, { - button: 2, - clientX: 18, - clientY: 27, - }); - - expect(capturedSelection).toBe("alpha"); - expect(terminal.getSelection()).toBe("charlie"); - terminal.dispose(); - }); -}); diff --git a/apps/app/src/components/thread/terminal/useThreadTerminalController.mount.test.tsx b/apps/app/src/components/thread/terminal/useThreadTerminalController.mount.test.tsx deleted file mode 100644 index 0df43982353..00000000000 --- a/apps/app/src/components/thread/terminal/useThreadTerminalController.mount.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, renderHook, waitFor } from "@testing-library/react"; -import type { TerminalSession } from "@bb/server-contract"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { sdk } from "@/lib/sdk"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { - useThreadTerminalController, - type ThreadTerminalControllerArgs, -} from "./useThreadTerminalController"; -import { makeTerminalSession } from "@/test/fixtures/terminal-sessions"; - -vi.mock("@/lib/sdk", () => ({ - sdk: { terminals: { list: vi.fn() } }, -})); - -const session: TerminalSession = makeTerminalSession({ - id: "term_1", - threadId: "thr_1", - environmentId: "env_1", - hostId: "host_1", - createdAt: 1, - updatedAt: 1, -}); - -interface PanelVisibility { - isPanelOpen: boolean; - isPanelPersistedOpen: boolean; -} - -function controllerArgs( - visibility: PanelVisibility, -): ThreadTerminalControllerArgs { - return { - isPanelOpen: visibility.isPanelOpen, - isPanelPersistedOpen: visibility.isPanelPersistedOpen, - target: { kind: "thread", threadId: "thr_1" }, - terminalId: session.id, - }; -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("useThreadTerminalController terminal view mounting", () => { - it("does not mount a persisted-open terminal the panel never showed", () => { - vi.mocked(sdk.terminals.list).mockResolvedValue({ sessions: [session] }); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => - useThreadTerminalController( - controllerArgs({ isPanelOpen: false, isPanelPersistedOpen: true }), - ), - { wrapper }, - ); - - expect(result.current.shouldMountTerminalView).toBe(false); - expect(sdk.terminals.list).not.toHaveBeenCalled(); - }); - - it("mounts the view for an open panel that is not persisted open", () => { - vi.mocked(sdk.terminals.list).mockResolvedValue({ sessions: [session] }); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => - useThreadTerminalController( - controllerArgs({ isPanelOpen: true, isPanelPersistedOpen: false }), - ), - { wrapper }, - ); - - expect(result.current.shouldMountTerminalView).toBe(true); - }); - - it("keeps the view mounted across a compact close and unmounts once persisted state closes", async () => { - vi.mocked(sdk.terminals.list).mockResolvedValue({ sessions: [session] }); - const { wrapper } = createQueryClientTestHarness(); - const { result, rerender } = renderHook( - (visibility: PanelVisibility) => - useThreadTerminalController(controllerArgs(visibility)), - { - wrapper, - initialProps: { isPanelOpen: true, isPanelPersistedOpen: true }, - }, - ); - - await waitFor(() => { - expect(result.current.activeSession?.id).toBe(session.id); - }); - expect(result.current.shouldMountTerminalView).toBe(true); - - rerender({ isPanelOpen: false, isPanelPersistedOpen: true }); - expect(result.current.shouldMountTerminalView).toBe(true); - expect(result.current.activeSession?.id).toBe(session.id); - - rerender({ isPanelOpen: false, isPanelPersistedOpen: false }); - expect(result.current.shouldMountTerminalView).toBe(false); - - rerender({ isPanelOpen: false, isPanelPersistedOpen: true }); - expect(result.current.shouldMountTerminalView).toBe(false); - rerender({ isPanelOpen: true, isPanelPersistedOpen: true }); - expect(result.current.shouldMountTerminalView).toBe(true); - }); - - it("never shows a sibling terminal in place of its own missing terminal", async () => { - const sibling = makeTerminalSession({ - id: "term_sibling", - threadId: "thr_1", - }); - vi.mocked(sdk.terminals.list).mockResolvedValue({ sessions: [sibling] }); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => - useThreadTerminalController( - controllerArgs({ isPanelOpen: true, isPanelPersistedOpen: true }), - ), - { wrapper }, - ); - - await waitFor(() => { - expect(sdk.terminals.list).toHaveBeenCalled(); - }); - expect(result.current.activeSession).toBeNull(); - }); -}); diff --git a/apps/app/src/components/thread/terminal/useThreadTerminalController.test.ts b/apps/app/src/components/thread/terminal/useThreadTerminalController.test.ts new file mode 100644 index 00000000000..5d635a74f7f --- /dev/null +++ b/apps/app/src/components/thread/terminal/useThreadTerminalController.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { makeTerminalSession } from "@/test/fixtures/terminal-sessions"; +import { + resolveTerminalPanelMount, + selectActiveTerminalSession, +} from "./useThreadTerminalController"; + +type PanelStep = { isPanelOpen: boolean; isPanelPersistedOpen: boolean }; + +function runPanelSteps(initiallyOpen: boolean, steps: readonly PanelStep[]) { + let hasPanelOpened = initiallyOpen; + return steps.map((step) => { + const mount = resolveTerminalPanelMount({ hasPanelOpened, ...step }); + hasPanelOpened = mount.hasPanelOpened; + return mount.shouldMountTerminalView; + }); +} + +describe("resolveTerminalPanelMount", () => { + it("does not mount or fetch a persisted-open terminal the panel never showed", () => { + expect( + resolveTerminalPanelMount({ + hasPanelOpened: false, + isPanelOpen: false, + isPanelPersistedOpen: true, + }), + ).toEqual({ + hasPanelOpened: false, + shouldFetchTerminals: false, + shouldMountTerminalView: false, + }); + }); + + it("mounts and fetches for an open panel that is not persisted open", () => { + expect( + resolveTerminalPanelMount({ + hasPanelOpened: false, + isPanelOpen: true, + isPanelPersistedOpen: false, + }), + ).toEqual({ + hasPanelOpened: true, + shouldFetchTerminals: true, + shouldMountTerminalView: true, + }); + }); + + it("keeps the view mounted across a compact close and unmounts once persisted state closes", () => { + expect( + runPanelSteps(true, [ + { isPanelOpen: true, isPanelPersistedOpen: true }, + { isPanelOpen: false, isPanelPersistedOpen: true }, + { isPanelOpen: false, isPanelPersistedOpen: false }, + { isPanelOpen: false, isPanelPersistedOpen: true }, + { isPanelOpen: true, isPanelPersistedOpen: true }, + ]), + ).toEqual([true, true, false, false, true]); + }); +}); + +describe("selectActiveTerminalSession", () => { + const own = makeTerminalSession({ id: "term_1", threadId: "thr_1" }); + const sibling = makeTerminalSession({ + id: "term_sibling", + threadId: "thr_1", + }); + const target = { kind: "thread", threadId: "thr_1" } as const; + + it("selects the panel's own terminal", () => { + expect( + selectActiveTerminalSession({ + sessions: [sibling, own], + target, + terminalId: "term_1", + }), + ).toBe(own); + }); + + it("never shows a sibling terminal in place of its own missing terminal", () => { + expect( + selectActiveTerminalSession({ + sessions: [sibling], + target, + terminalId: "term_1", + }), + ).toBeNull(); + }); +}); diff --git a/apps/app/src/components/thread/terminal/useThreadTerminalController.ts b/apps/app/src/components/thread/terminal/useThreadTerminalController.ts index 38a425c4c1d..702073838e1 100644 --- a/apps/app/src/components/thread/terminal/useThreadTerminalController.ts +++ b/apps/app/src/components/thread/terminal/useThreadTerminalController.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { TerminalCreateTarget, @@ -46,16 +46,53 @@ interface TerminalTitleRenameRequest { type ThreadTerminalTitleChangeHandler = (title: string) => void; type TerminalTitleRenameTimeout = number; -function shouldMountTerminalViewForPanel({ +interface TerminalPanelVisibility { + hasPanelOpened: boolean; + isPanelOpen: boolean; + isPanelPersistedOpen: boolean; +} + +export function resolveTerminalPanelMount({ hasPanelOpened, isPanelOpen, isPanelPersistedOpen, -}: { +}: TerminalPanelVisibility): { hasPanelOpened: boolean; - isPanelOpen: boolean; - isPanelPersistedOpen: boolean; -}): boolean { - return isPanelOpen || (isPanelPersistedOpen && hasPanelOpened); + shouldFetchTerminals: boolean; + shouldMountTerminalView: boolean; +} { + const nextHasPanelOpened = isPanelOpen + ? true + : isPanelPersistedOpen && hasPanelOpened; + return { + hasPanelOpened: nextHasPanelOpened, + shouldFetchTerminals: isPanelOpen, + shouldMountTerminalView: + isPanelOpen || (isPanelPersistedOpen && nextHasPanelOpened), + }; +} + +export function selectActiveTerminalSession({ + sessions, + target, + terminalId, +}: { + sessions: readonly TerminalSession[]; + target: TerminalCreateTarget; + terminalId: string; +}): TerminalSession | null { + return ( + sessions.find( + (session) => + session.id === terminalId && + isVisibleTerminalSession(session) && + (target.kind !== "host_path" || + (session.threadId === null && + session.environmentId === null && + session.hostId === target.hostId && + (target.cwd === null || session.initialCwd === target.cwd))), + ) ?? null + ); } export function useThreadTerminalController({ @@ -70,40 +107,24 @@ export function useThreadTerminalController({ const pendingTitleRenameTimeoutRef = useRef(null); const [hasPanelOpened, setHasPanelOpened] = useState(isPanelOpen); - if (isPanelOpen && !hasPanelOpened) { - setHasPanelOpened(true); - } else if (!isPanelOpen && !isPanelPersistedOpen && hasPanelOpened) { - setHasPanelOpened(false); - } - const shouldMountTerminalView = shouldMountTerminalViewForPanel({ + const panelMount = resolveTerminalPanelMount({ hasPanelOpened, isPanelOpen, isPanelPersistedOpen, }); + if (panelMount.hasPanelOpened !== hasPanelOpened) { + setHasPanelOpened(panelMount.hasPanelOpened); + } + const { shouldMountTerminalView } = panelMount; const terminalsQuery = useTerminals(terminalQueryScopeForTarget(target), { - enabled: isPanelOpen, + enabled: panelMount.shouldFetchTerminals, }); const renameTerminal = useRenameTerminal(); - const sessions = useMemo(() => { - const currentSessions = - terminalsQuery.data?.sessions ?? EMPTY_TERMINAL_SESSIONS; - if (target.kind !== "host_path") { - return currentSessions; - } - return currentSessions.filter( - (session) => - session.threadId === null && - session.environmentId === null && - session.hostId === target.hostId && - (target.cwd === null || session.initialCwd === target.cwd), - ); - }, [target, terminalsQuery.data?.sessions]); - const visibleSessions = useMemo( - () => sessions.filter(isVisibleTerminalSession), - [sessions], - ); - const activeSession = - visibleSessions.find((session) => session.id === terminalId) ?? null; + const activeSession = selectActiveTerminalSession({ + sessions: terminalsQuery.data?.sessions ?? EMPTY_TERMINAL_SESSIONS, + target, + terminalId, + }); useEffect(() => { return () => { diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx index ccb2c15f94a..6ba563b541d 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.streaming.test.tsx @@ -77,89 +77,6 @@ beforeEach(() => { afterEach(cleanup); describe("ConversationMessageContent streaming split", () => { - it.each([ - ["**Streaming bold", "**Streaming bold**"], - ["`streaming code", "`streaming code`"], - ["Read [the docs](https://example", "Read the docs"], - ["Image ![preview](/workspace/preview", "Image "], - ])( - "repairs an unsplit live tail and restores raw source on completion: %s", - (source, repaired) => { - const { view, update } = renderAssistantMessage(source, true); - expect(documents(view.container)).toEqual([repaired]); - - update(source, false); - expect(documents(view.container)).toEqual([source]); - }, - ); - - it("repairs only the live split tail without re-parsing the settled prefix", () => { - const { view, update } = renderAssistantMessage( - "Settled **source.\n\nSecond paragraph.\n\n`live code", - true, - ); - expect(documents(view.container)).toEqual([ - "Settled **source.\n\n", - "Second paragraph.\n\n`live code`", - ]); - - markdownRenders.length = 0; - update("Settled **source.\n\nSecond paragraph.\n\n`live code grows", true); - expect(markdownRenders).toEqual([ - "Second paragraph.\n\n", - "`live code grows`", - ]); - - markdownRenders.length = 0; - update( - "Settled **source.\n\nSecond paragraph.\n\n`live code grows more", - true, - ); - expect(markdownRenders).toEqual(["`live code grows more`"]); - }); - - it("repairs unfinished formatting after ordinary double-colon text", () => { - const source = "Call Namespace::Method, then **live bold"; - const { view, update } = renderAssistantMessage(source, true); - expect(documents(view.container)).toEqual([ - "Call Namespace::Method, then **live bold**", - ]); - - update(`Settled.\n\nSecond paragraph.\n\n${source}`, true); - expect(documents(view.container)).toEqual([ - "Settled.\n\n", - `Second paragraph.\n\n${source}**`, - ]); - }); - - it.each([ - "::inline-vis[label", - " ::inline-vis[label", - "> ::inline-vis[label", - "- ::inline-vis[label", - '::inline-vis{file="[draft.html"}', - '::inline-vis{file="a__b.html"}', - '::unknown{title="**source"}', - ])("preserves directive source in a live tail: %s", (source) => { - const { view } = renderAssistantMessage(source, true); - expect(documents(view.container)).toEqual([source]); - }); - - it("resumes repair after a directive moves into the settled prefix", () => { - const source = '::inline-vis{file="[draft.html"}'; - const { view, update } = renderAssistantMessage( - source + "\n\n**live", - true, - ); - expect(documents(view.container)).toEqual([source + "\n\n**live"]); - - update(source + "\n\nSecond paragraph.\n\n**live", true); - expect(documents(view.container)).toEqual([ - source + "\n\n", - "Second paragraph.\n\n**live**", - ]); - }); - it("re-parses only the live tail when a delta arrives and collapses to one document once complete", () => { const { view, update } = renderAssistantMessage( "Para one.\n\nPara two.\n\nPara th", @@ -197,34 +114,4 @@ describe("ConversationMessageContent streaming split", () => { ]); expect(markdownRenders).toEqual(["Para three.\n\n", "Para four."]); }); - - it.each([ - '~~~ts\nconst x = "**text";\n', - '> ~~~ts\n> const x = "**text";\n', - '- ```ts\n const x = "**text";\n', - ])("preserves fenced code verbatim in the live tail: %s", (source) => { - const { view } = renderAssistantMessage(source, true); - expect(documents(view.container)).toEqual([source]); - }); - - it("keeps an open fenced block inside the live tail", () => { - const { view } = renderAssistantMessage( - "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n", - true, - ); - expect(documents(view.container)).toEqual([ - "Intro.\n\n", - "```ts\nconst a = 1;\n\nconst b = 2;\n", - ]); - }); - - it("renders a single document when no boundary is available or when not streaming", () => { - const { view, update } = renderAssistantMessage("Only one paragraph", true); - expect(documents(view.container)).toEqual(["Only one paragraph"]); - - update("Para one.\n\nPara two.\n\nPara three", false); - expect(documents(view.container)).toEqual([ - "Para one.\n\nPara two.\n\nPara three", - ]); - }); }); diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index 02174f6c068..2343922f9a5 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -56,10 +56,7 @@ import { USER_MESSAGE_CHAR_CAP, } from "@bb/client-core"; import { turnRequestLabel } from "@bb/client-core"; -import { - repairStreamingMarkdownTail, - splitStreamingMarkdown, -} from "./streaming-markdown-split.js"; +import { resolveStreamingMarkdown } from "./streaming-markdown-split.js"; import { TurnRequestLabel } from "./TurnRequestLabel.js"; import { MessageActionBar, @@ -516,14 +513,10 @@ function AssistantConversationMessage({ turnId, workspaceRootPath, }: AssistantConversationMessageProps) { - const streamingSplit = useMemo( - () => (streaming ? splitStreamingMarkdown(text) : null), + const { split: streamingSplit, liveMarkdown } = useMemo( + () => resolveStreamingMarkdown(text, streaming), [streaming, text], ); - const liveMarkdown = useMemo(() => { - const tail = streamingSplit?.tail ?? text; - return streaming ? repairStreamingMarkdownTail(tail) : tail; - }, [streaming, streamingSplit, text]); const linkRouting = useMemo( () => buildMarkdownMessageLinkRouting({ diff --git a/apps/app/src/components/thread/timeline/LazyTimelineFileDiffBlock.test.tsx b/apps/app/src/components/thread/timeline/LazyTimelineFileDiffBlock.test.tsx deleted file mode 100644 index 0c7603fa16d..00000000000 --- a/apps/app/src/components/thread/timeline/LazyTimelineFileDiffBlock.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, expect, it, vi } from "vitest"; -import { - commandRow, - fileChangeRow, -} from "@/test/fixtures/thread-timeline-rows"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { ThreadTimelineRows } from "./ThreadTimelineRows"; - -vi.mock("./TimelineFileDiffBlock.js", () => { - throw new Error("chunk download failed"); -}); - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -it("keeps a failed diff download inside its file-change row", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - const { wrapper: Wrapper } = createQueryClientTestHarness(); - const change = fileChangeRow({ - id: "file_1", - stderr: "patch rejected", - sourceSeqStart: 1, - sourceSeqEnd: 2, - }); - const next = commandRow({ - id: "cmd_1", - command: "pnpm test", - sourceSeqStart: 3, - sourceSeqEnd: 4, - }); - - render( - - - - - , - ); - - expect((await screen.findByRole("alert")).textContent).toContain( - "Could not load", - ); - screen.getByText("patch rejected"); - screen.getByText("pnpm test"); -}); diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.ts b/apps/app/src/components/thread/timeline/MessageActionBar.test.ts new file mode 100644 index 00000000000..d18ed0f5fbf --- /dev/null +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageActionRowLayout } from "./MessageActionBar"; + +describe("computeMessageActionRowLayout", () => { + const metrics = { actionWidth: 20 }; + + it("renders every candidate inline before the slot is measured", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 5, + availableWidth: undefined, + ...metrics, + }), + ).toEqual({ inlineCount: 5, overflowCount: 0 }); + }); + + it("reserves space for the always-present menu trigger", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 100, + ...metrics, + }), + ).toEqual({ inlineCount: 3, overflowCount: 0 }); + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 99, + ...metrics, + }), + ).toEqual({ inlineCount: 2, overflowCount: 1 }); + }); + + it("moves candidates into overflow from the end", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 72, + ...metrics, + }), + ).toEqual({ inlineCount: 2, overflowCount: 1 }); + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 71, + ...metrics, + }), + ).toEqual({ inlineCount: 1, overflowCount: 2 }); + }); + + it("puts every candidate in the menu when none fit beside the trigger", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 3, + availableWidth: 30, + ...metrics, + }), + ).toEqual({ inlineCount: 0, overflowCount: 3 }); + }); + + it("returns an empty layout for zero candidates", () => { + expect( + computeMessageActionRowLayout({ + actionCount: 0, + availableWidth: 400, + ...metrics, + }), + ).toEqual({ inlineCount: 0, overflowCount: 0 }); + }); +}); diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx index 05ae604f512..0ae41cb4d25 100644 --- a/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx +++ b/apps/app/src/components/thread/timeline/MessageActionBar.test.tsx @@ -16,7 +16,6 @@ import { import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; import { POINTER_COARSE_QUERY } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { - computeMessageActionRowLayout, findMessageActionTooltipCollisionBoundary, MessageActionBar, } from "./MessageActionBar"; @@ -460,71 +459,3 @@ describe("MessageActionBar", () => { expect(row?.hasAttribute("data-menu-open")).toBe(false); }); }); - -describe("computeMessageActionRowLayout", () => { - const metrics = { actionWidth: 20 }; - - it("renders every candidate inline before the slot is measured", () => { - expect( - computeMessageActionRowLayout({ - actionCount: 5, - availableWidth: undefined, - ...metrics, - }), - ).toEqual({ inlineCount: 5, overflowCount: 0 }); - }); - - it("reserves space for the always-present menu trigger", () => { - expect( - computeMessageActionRowLayout({ - actionCount: 3, - availableWidth: 100, - ...metrics, - }), - ).toEqual({ inlineCount: 3, overflowCount: 0 }); - expect( - computeMessageActionRowLayout({ - actionCount: 3, - availableWidth: 99, - ...metrics, - }), - ).toEqual({ inlineCount: 2, overflowCount: 1 }); - }); - - it("moves candidates into overflow from the end", () => { - expect( - computeMessageActionRowLayout({ - actionCount: 3, - availableWidth: 72, - ...metrics, - }), - ).toEqual({ inlineCount: 2, overflowCount: 1 }); - expect( - computeMessageActionRowLayout({ - actionCount: 3, - availableWidth: 71, - ...metrics, - }), - ).toEqual({ inlineCount: 1, overflowCount: 2 }); - }); - - it("puts every candidate in the menu when none fit beside the trigger", () => { - expect( - computeMessageActionRowLayout({ - actionCount: 3, - availableWidth: 30, - ...metrics, - }), - ).toEqual({ inlineCount: 0, overflowCount: 3 }); - }); - - it("returns an empty layout for zero candidates", () => { - expect( - computeMessageActionRowLayout({ - actionCount: 0, - availableWidth: 400, - ...metrics, - }), - ).toEqual({ inlineCount: 0, overflowCount: 0 }); - }); -}); diff --git a/apps/app/src/components/thread/timeline/TerminalOutputBlock.test.tsx b/apps/app/src/components/thread/timeline/TerminalOutputBlock.test.tsx deleted file mode 100644 index 412f9d0a056..00000000000 --- a/apps/app/src/components/thread/timeline/TerminalOutputBlock.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, expect, it } from "vitest"; -import { ExpandableTimelineRow } from "./ExpandableTimelineRow"; -import { TerminalOutputBlock } from "./TerminalOutputBlock"; - -afterEach(cleanup); - -it("reveals the complete command with the outer timeline row", () => { - const commandLine = [ - "$ run-task \\", - " --first-option alpha \\", - " --second-option beta \\", - " --third-option gamma", - ].join("\n"); - - render( - ( - - )} - />, - ); - - fireEvent.click(screen.getByRole("button", { name: "Ran command" })); - - const renderedCommand = screen.getByText( - (_, element) => - element?.childNodes.length === 1 && element.textContent === commandLine, - ); - expect(renderedCommand.closest("button")).toBeNull(); - expect(renderedCommand.className).not.toContain("max-h-[2lh]"); - expect(renderedCommand.style.webkitLineClamp).toBe(""); -}); diff --git a/apps/app/src/components/thread/timeline/ThreadContextWindowIndicator.test.tsx b/apps/app/src/components/thread/timeline/ThreadContextWindowIndicator.test.tsx deleted file mode 100644 index 34126d3838c..00000000000 --- a/apps/app/src/components/thread/timeline/ThreadContextWindowIndicator.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, expect, it } from "vitest"; -import type { ThreadContextWindowUsage } from "@bb/server-contract"; -import { ThreadContextWindowCard } from "./ThreadContextWindowIndicator"; - -afterEach(cleanup); - -it("shows details when a snapshot arrives and removes them when only aggregate usage remains", () => { - const usage: ThreadContextWindowUsage = { - usedTokens: 1000, - modelContextWindow: 10000, - estimated: true, - snapshot: { - capturedAt: "2026-09-11T00:00:00.000Z", - providerSessionId: "session", - providerTurnId: null, - model: "model", - usedTokens: 1000, - contextWindowTokens: 10000, - autoCompactAtTokens: null, - estimated: true, - categories: [ - { - id: "messages", - label: "Messages", - kind: "used", - tokens: 1000, - entries: [], - }, - ], - }, - }; - const aggregateUsage = { - usedTokens: usage.usedTokens, - modelContextWindow: usage.modelContextWindow, - estimated: usage.estimated, - }; - const { rerender } = render( - , - ); - expect(screen.getByText("10% used")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Show details" })).toBeNull(); - rerender(); - fireEvent.click(screen.getByRole("button", { name: "Show details" })); - expect(screen.getByText("Messages")).toBeTruthy(); - rerender(); - expect(screen.queryByText("Messages")).toBeNull(); - expect(screen.queryByRole("button", { name: "Hide details" })).toBeNull(); - expect(screen.getByText("10% used")).toBeTruthy(); -}); - -it("shows the session cost only when the provider reports one", () => { - const usage: ThreadContextWindowUsage = { - usedTokens: 1000, - modelContextWindow: 10000, - estimated: false, - }; - const { rerender } = render(); - expect(screen.queryByText("Session cost")).toBeNull(); - rerender( - , - ); - expect(screen.getByText("Session cost")).toBeTruthy(); - expect(screen.getByText(/1\.50/)).toBeTruthy(); -}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx deleted file mode 100644 index 3fae9f07bc1..00000000000 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; -import type { TimelineWorkflowWorkRow } from "@bb/server-contract"; -import { ThreadTimelinePanelContent } from "./ThreadTimelinePanelContent.js"; -import type { UseThreadTimelineControllerResult } from "./useThreadTimelineController.js"; - -const mocks = vi.hoisted(() => ({ - activeBackgroundAgentCount: 0, - displayStatus: "idle" as ThreadRuntimeDisplayStatus, - threadStatus: "idle", -})); - -vi.mock("@/hooks/queries/thread-queries", () => ({ - useThread: () => ({ - data: { - activeBackgroundAgentCount: mocks.activeBackgroundAgentCount, - runtime: { displayStatus: mocks.displayStatus }, - status: mocks.threadStatus, - }, - error: null, - }), -})); - -vi.mock("./ThreadTimelineSurface.js", () => ({ - ThreadTimelineSurface: ({ - ongoingIndicatorLabel, - showOngoingIndicator, - }: { - ongoingIndicatorLabel?: string; - showOngoingIndicator: boolean; - }) => ( -
- {showOngoingIndicator ? ( -
{ongoingIndicatorLabel ?? "Working..."}
- ) : null} -
- ), -})); - -vi.mock("./useThreadTimelineController.js", () => ({ - useThreadTimelineController: () => ({ - activePromptMode: null, - activeThinking: null, - activeWorkflows: [], - activeBackgroundCommands: [], - contextBoundarySeq: null, - contextWindowUsage: undefined, - goal: null, - providerCommands: null, - sessionOptions: null, - modelFallback: null, - hasOlderTimelineRows: false, - isCatchingUpTimeline: false, - isLoadingOlderTimelineRows: false, - loadOlderTimelineRows: vi.fn(), - pendingTodos: null, - timelineError: null, - timelineLoading: false, - timelineRows: [], - }), -})); - -vi.mock("@/components/ui/conversation.js", () => ({ - ConversationTimeline: ({ children }: { children?: ReactNode }) => ( -
{children}
- ), -})); - -function workflowRow(): TimelineWorkflowWorkRow { - return { - id: "thr-test:workflow:task:wf-open", - threadId: "thr-test", - turnId: null, - sourceSeqStart: 1, - sourceSeqEnd: 1, - startedAt: 1, - createdAt: 1, - kind: "work", - status: "pending", - workKind: "workflow", - itemId: "task:wf-open", - taskType: "local_workflow", - workflowName: "fixture-mini", - description: "fixture workflow", - model: null, - taskStatus: "running", - workflow: null, - usage: null, - summary: null, - error: null, - completedAt: null, - }; -} - -function baseTimeline( - overrides: Partial = {}, -): UseThreadTimelineControllerResult { - return { - activePromptMode: null, - activeThinking: null, - activeWorkflows: [], - activeBackgroundCommands: [], - contextWindowUsage: undefined, - goal: null, - providerCommands: null, - sessionOptions: null, - hasOlderTimelineRows: false, - isCatchingUpTimeline: false, - isLoadingOlderTimelineRows: false, - loadOlderTimelineRows: vi.fn(), - pendingTodos: null, - timelineError: null, - timelineLoading: false, - timelineRows: [], - ...overrides, - contextBoundarySeq: overrides.contextBoundarySeq ?? null, - modelFallback: overrides.modelFallback ?? null, - }; -} - -afterEach(() => { - cleanup(); - mocks.activeBackgroundAgentCount = 0; - mocks.displayStatus = "idle"; - mocks.threadStatus = "idle"; -}); - -describe("ThreadTimelinePanelContent", () => { - it("shows a background-only working indicator while runtime is idle", () => { - render( - , - ); - - expect(screen.getByText("Background work running")).not.toBeNull(); - }); - - it("keeps the normal working label while runtime is active", () => { - mocks.displayStatus = "active"; - - render( - , - ); - - expect(screen.queryByText("Background work running")).toBeNull(); - expect(screen.getByText("Working...")).not.toBeNull(); - }); - - it("shows a background indicator for an idle Claude thread with only a nested agent active", () => { - mocks.activeBackgroundAgentCount = 1; - - render( - , - ); - - expect(screen.getByText("Background work running")).not.toBeNull(); - expect(screen.queryByText("Working...")).toBeNull(); - }); - - it("hides the background indicator when the nested agent count returns to zero", () => { - mocks.activeBackgroundAgentCount = 1; - - const { rerender } = render( - , - ); - - expect(screen.getByText("Background work running")).not.toBeNull(); - - mocks.activeBackgroundAgentCount = 0; - rerender( - , - ); - - expect(screen.queryByText("Background work running")).toBeNull(); - expect(screen.queryByText("Working...")).toBeNull(); - }); -}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx index a601b71e73d..4feef84734b 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { ThreadTimelinePanelLoadingSkeleton } from "./ThreadChatLoading"; +import { resolveThreadOngoingIndicator } from "./thread-ongoing-indicator"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { ConversationTimeline } from "@/components/ui/conversation.js"; import { useThread } from "@/hooks/queries/thread-queries"; @@ -56,26 +56,18 @@ export function ThreadTimelinePanelContent({ }); const resolvedTimeline = timeline ?? ownedTimeline; const displayStatus = threadQuery.data?.runtime.displayStatus ?? "idle"; - const isProvisioningDisplayStatus = - displayStatus === "provisioning" || displayStatus === "starting"; - const hasActiveBackgroundWork = - resolvedTimeline.activeWorkflows.length > 0 || - resolvedTimeline.activeBackgroundCommands.length > 0 || - (threadQuery.data?.activeBackgroundAgentCount ?? 0) > 0; - const backgroundOnlyIndicatorLabel = - displayStatus === "idle" && hasActiveBackgroundWork - ? "Background work running" - : undefined; - const ongoingIndicatorLabel = isProvisioningDisplayStatus - ? "Provisioning thread..." - : backgroundOnlyIndicatorLabel; - const showOngoingIndicator = - threadQuery.data?.status !== "stopping" && - (isProvisioningDisplayStatus || - (!resolvedTimeline.timelineLoading && - (isTurnSubmitting || - isRunningThreadRuntimeDisplayStatus(displayStatus) || - backgroundOnlyIndicatorLabel !== undefined))); + const ongoingIndicator = resolveThreadOngoingIndicator({ + displayStatus, + activeBackgroundAgentCount: + threadQuery.data?.activeBackgroundAgentCount ?? 0, + activeBackgroundCommandCount: + resolvedTimeline.activeBackgroundCommands.length, + activeWorkflowCount: resolvedTimeline.activeWorkflows.length, + isStopping: threadQuery.data?.status === "stopping", + isTurnSubmitting, + timelineLoading: resolvedTimeline.timelineLoading, + }); + const showOngoingIndicator = ongoingIndicator.show; const timelineRows = resolvedTimeline.timelineRows; const isChildThreadMissing = threadQuery.error instanceof BbHttpError && @@ -119,7 +111,7 @@ export function ThreadTimelinePanelContent({ projectId={projectId} resolveMentionLink={resolveMentionLink} showOngoingIndicator={showOngoingIndicator} - ongoingIndicatorLabel={ongoingIndicatorLabel} + ongoingIndicatorLabel={ongoingIndicator.label} timelineErrorClassName="mx-2 mt-4 text-destructive" timelineRows={timelineRows} threadId={threadId} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.render-stability.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.render-stability.test.tsx deleted file mode 100644 index b128dcd305e..00000000000 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.render-stability.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// @vitest-environment jsdom - -import { Profiler, type ProfilerOnRenderCallback } from "react"; -import { act, cleanup, render, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import { threadsQueryKey } from "@/hooks/queries/query-keys"; -import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; -import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; -import { ThreadTimelineRows } from "./ThreadTimelineRows"; -import { sdk } from "@/lib/sdk"; -import { createQueryNotificationScheduler } from "@/test/queryNotificationScheduler"; - -const notifications = createQueryNotificationScheduler(); - -beforeEach(() => notifications.install()); - -function timelineRowsFixture() { - const rows = [ - conversationRow({ - id: "agent_sourced_message", - role: "user", - initiator: "agent", - senderThreadId: "thr_sender", - text: "Message from the sender thread.", - sourceSeqStart: 1, - sourceSeqEnd: 1, - threadId: "thr_main", - }), - ]; - for (let index = 0; index < 20; index += 1) { - rows.push( - conversationRow({ - id: `assistant_message_${index}`, - role: "assistant", - text: `Assistant answer number ${index}.`, - sourceSeqStart: 10 + index, - sourceSeqEnd: 10 + index, - threadId: "thr_main", - }), - ); - } - return rows; -} - -function renderProfiledTimeline(queryClient: QueryClient) { - const commits: { phase: string }[] = []; - const onRender: ProfilerOnRenderCallback = (_id, phase) => { - commits.push({ phase }); - }; - const view = render( - - - - - - - , - ); - return { commits, view }; -} - -afterEach(() => { - cleanup(); - notifications.restore(); - vi.restoreAllMocks(); -}); - -describe("ThreadTimelineRows render stability", () => { - it("routes a personal-project sender pill directly from sender metadata", async () => { - const getThread = vi.spyOn(sdk.threads, "get"); - const queryClient = new QueryClient(); - queryClient.setQueryData(threadsQueryKey(), [ - makeThreadListEntry({ - id: "thr_sender", - projectId: PERSONAL_PROJECT_ID, - title: "Personal sender", - }), - ]); - const { view } = renderProfiledTimeline(queryClient); - - expect( - view.getByRole("link", { name: "Personal sender" }).getAttribute("href"), - ).toBe("/threads/thr_sender"); - await notifications.flush(); - expect(getThread).not.toHaveBeenCalled(); - }); - - it("does not re-render the timeline when cache events carry equal thread metadata", async () => { - const queryClient = new QueryClient(); - queryClient.setQueryData(threadsQueryKey(), [ - makeThreadListEntry({ id: "thr_sender", title: "Sender thread" }), - ]); - const { commits, view } = renderProfiledTimeline(queryClient); - expect(view.getByText("Sender thread")).toBeTruthy(); - await notifications.flush(); - const settledCommitCount = commits.length; - - for (let round = 0; round < 10; round += 1) { - await act(async () => { - queryClient.setQueryData(threadsQueryKey(), [ - makeThreadListEntry({ id: "thr_sender", title: "Sender thread" }), - ]); - }); - } - await notifications.flush(); - - expect(commits.length).toBe(settledCommitCount); - }); - - it("re-renders and shows the new sender title when metadata actually changes", async () => { - const queryClient = new QueryClient(); - queryClient.setQueryData(threadsQueryKey(), [ - makeThreadListEntry({ id: "thr_sender", title: null }), - ]); - const { view } = renderProfiledTimeline(queryClient); - expect(view.getByText("Agent")).toBeTruthy(); - - await act(async () => { - queryClient.setQueryData(threadsQueryKey(), [ - makeThreadListEntry({ id: "thr_sender", title: "Sender thread" }), - ]); - }); - - await waitFor(() => { - expect(view.getByText("Sender thread")).toBeTruthy(); - }); - }); -}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index 55a34de16d0..c9f5500c08e 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -1964,7 +1964,7 @@ function isUnreadDividerCandidateAfterCutoff({ return !isUserAuthoredConversationRow(row); } -function buildTimelineRowsListItems({ +export function buildTimelineRowsListItems({ rows, unreadDividerPlacement, }: BuildTimelineRowsListItemsArgs): TimelineRowsListItem[] { diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.ts similarity index 65% rename from apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.tsx rename to apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.ts index e8aae6ad874..e85f5593b4b 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.unread-divider.test.ts @@ -1,9 +1,5 @@ -// @vitest-environment jsdom - -import { cleanup, render } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import { MemoryRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { buildTimelineViewRows } from "@bb/thread-view"; +import { describe, expect, it } from "vitest"; import type { TimelineRow } from "@bb/server-contract"; import { commandRow, @@ -11,44 +7,21 @@ import { turnRow, } from "@/test/fixtures/thread-timeline-rows"; import type { ThreadTimelineUnreadDividerPlacement } from "./types"; -import { ThreadProviderContext } from "../thread-provider-context"; -import { ThreadTimelineRows } from "./ThreadTimelineRows"; +import { buildTimelineRowsListItems } from "./ThreadTimelineRows"; const DIVIDER = "__divider__"; -afterEach(cleanup); - function renderTopLevelSequence( timelineRows: TimelineRow[], unreadDividerPlacement: ThreadTimelineUnreadDividerPlacement | null, ): string[] { - const { container } = render( - - - - - - - , - ); - const list = container.querySelector('[data-timeline-row-list="top-level"]'); - if (list === null) { - throw new Error("Timeline did not render a top-level row list"); - } - return [...list.querySelectorAll(":scope > [data-timeline-items] > *")].map( - (child) => - child.querySelector('[data-testid="thread-unread-divider"]') === null - ? (child.textContent ?? "") - : DIVIDER, - ); + return buildTimelineRowsListItems({ + rows: buildTimelineViewRows(timelineRows), + unreadDividerPlacement, + }).map((item) => { + if (item.kind !== "row") return DIVIDER; + return item.row.kind === "conversation" ? item.row.role : item.row.kind; + }); } function userMessage(createdAt: number): TimelineRow { @@ -99,7 +72,7 @@ describe("unread divider placement", () => { { kind: "after-cutoff", cutoffAt: 350 }, ); - expect(sequence).toEqual(["PROMPT", "Ran 3 commands", DIVIDER, "ANSWER"]); + expect(sequence).toEqual(["user", "step-summary", DIVIDER, "assistant"]); }); it("places the divider above a work group that started after the cutoff", () => { @@ -115,11 +88,11 @@ describe("unread divider placement", () => { ); expect(sequence).toEqual([ - "PROMPT", - "ANSWER", + "user", + "assistant", DIVIDER, - "Ran 2 commands", - "ANSWER", + "step-summary", + "assistant", ]); }); @@ -141,7 +114,7 @@ describe("unread divider placement", () => { { kind: "after-cutoff", cutoffAt: 350 }, ); - expect(collapsed).toEqual(["PROMPT", "Worked for 4s", DIVIDER, "ANSWER"]); + expect(collapsed).toEqual(["user", "turn", DIVIDER, "assistant"]); }); it("never anchors the divider on the reader's own message", () => { @@ -150,7 +123,7 @@ describe("unread divider placement", () => { { kind: "after-cutoff", cutoffAt: 300 }, ); - expect(sequence).toEqual(["ANSWER", "PROMPT", DIVIDER, "Ran echo 500 2s"]); + expect(sequence).toEqual(["assistant", "user", DIVIDER, "work"]); }); it("omits the divider when no row started after the cutoff", () => { diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.output-preview.test.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.output-preview.test.tsx index 3201dd09695..6e210f9e803 100644 --- a/apps/app/src/components/thread/timeline/TimelineRowDetails.output-preview.test.tsx +++ b/apps/app/src/components/thread/timeline/TimelineRowDetails.output-preview.test.tsx @@ -69,53 +69,6 @@ afterEach(() => { }); describe("previewed command output", () => { - it("loads the full output for an expanded finished row through row-scoped turn details", async () => { - timelineTurnSummaryDetails.mockResolvedValue({ - rows: [ - commandRow({ - id: "cmd_big", - command: "pnpm test", - output: FULL_OUTPUT, - sourceSeqStart: 4, - sourceSeqEnd: 7, - threadId: "thr_main", - turnId: "turn_1", - }), - ], - }); - const preview = previewedCommandRow(); - const view = renderExpandedRow(preview); - - await waitFor(() => { - expect(timelineTurnSummaryDetails).toHaveBeenCalledTimes(1); - }); - expect(timelineTurnSummaryDetails.mock.calls[0]?.[0]).toMatchObject({ - itemId: preview.callId, - threadId: "thr_main", - turnId: "turn_1", - sourceSeqStart: "4", - sourceSeqEnd: "7", - }); - await waitFor(() => { - expect(view.container.textContent).toContain(FULL_OUTPUT); - }); - expect(view.container.textContent).not.toContain("characters omitted"); - expect(screen.queryByTestId("timeline-output-preview-note")).toBeNull(); - }); - - it("keeps the live preview for a running row and does not fetch details", async () => { - const view = renderExpandedRow(previewedCommandRow({ status: "pending" })); - - await waitFor(() => { - expect(view.container.textContent).toContain("characters omitted"); - }); - expect( - screen.getByTestId("timeline-output-preview-note").textContent, - ).toContain("full output loads when this finishes"); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(timelineTurnSummaryDetails).not.toHaveBeenCalled(); - }); - it("shows the preview with a retry when the full-output load fails", async () => { timelineTurnSummaryDetails.mockRejectedValue(new Error("boom")); const view = renderExpandedRow(previewedCommandRow()); @@ -130,88 +83,4 @@ describe("previewed command output", () => { screen.getByTestId("timeline-output-preview-note").textContent, ).toContain("Failed to load the full output"); }); - - it("keeps a preview warning when row details cannot hydrate the full output", async () => { - const detailPreview = - "FULL-HEAD detail preview\n…[output remains truncated]\nFULL-TAIL"; - timelineTurnSummaryDetails.mockResolvedValue({ - rows: [ - { - ...commandRow({ - id: "cmd_big", - command: "pnpm test", - output: detailPreview, - sourceSeqStart: 4, - sourceSeqEnd: 7, - threadId: "thr_main", - turnId: "turn_1", - }), - outputPreview: { - experimental_fullOutputAvailability: "detail-limit", - totalChars: FULL_OUTPUT.length, - }, - }, - ], - }); - const view = renderExpandedRow(previewedCommandRow()); - - await waitFor(() => { - expect(view.container.textContent).toContain(detailPreview); - }); - expect( - screen.getByTestId("timeline-output-preview-note").textContent, - ).toContain("exceeds the detail response limit"); - expect(screen.queryByRole("button", { name: /retry/i })).toBeNull(); - }); - - it("does not request details for an already-expired retained output", async () => { - const row = previewedCommandRow(); - row.outputPreview = { - experimental_fullOutputAvailability: "retention-expired", - totalChars: FULL_OUTPUT.length, - }; - const view = renderExpandedRow(row); - - await waitFor(() => { - expect(view.container.textContent).toContain(PREVIEW_OUTPUT); - }); - expect( - screen.getByTestId("timeline-output-preview-note").textContent, - ).toContain("retention period ended"); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(timelineTurnSummaryDetails).not.toHaveBeenCalled(); - }); - - it("explains when output retention expires while details load", async () => { - const expiredPreview = - "FULL-HEAD expired preview\n…[output remains truncated]\nFULL-TAIL"; - timelineTurnSummaryDetails.mockResolvedValue({ - rows: [ - { - ...commandRow({ - id: "cmd_big", - command: "pnpm test", - output: expiredPreview, - sourceSeqStart: 4, - sourceSeqEnd: 7, - threadId: "thr_main", - turnId: "turn_1", - }), - outputPreview: { - experimental_fullOutputAvailability: "retention-expired", - totalChars: FULL_OUTPUT.length, - }, - }, - ], - }); - const view = renderExpandedRow(previewedCommandRow()); - - await waitFor(() => { - expect(view.container.textContent).toContain(expiredPreview); - }); - expect( - screen.getByTestId("timeline-output-preview-note").textContent, - ).toContain("retention period ended"); - expect(screen.queryByRole("button", { name: /retry/i })).toBeNull(); - }); }); diff --git a/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts b/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts index 12c1452847d..39d7b9bf888 100644 --- a/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts +++ b/apps/app/src/components/thread/timeline/streaming-markdown-split.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { splitStreamingMarkdown } from "./streaming-markdown-split"; +import { + resolveStreamingMarkdown, + splitStreamingMarkdown, +} from "./streaming-markdown-split"; function expectSplit(text: string, settled: string) { const split = splitStreamingMarkdown(text); @@ -112,3 +115,90 @@ describe("splitStreamingMarkdown", () => { ); }); }); + +function streamingDocuments(text: string, streaming: boolean): string[] { + const { split, liveMarkdown } = resolveStreamingMarkdown(text, streaming); + return split === null ? [liveMarkdown] : [split.settled, liveMarkdown]; +} + +describe("resolveStreamingMarkdown", () => { + it.each([ + ["**Streaming bold", "**Streaming bold**"], + ["`streaming code", "`streaming code`"], + ["Read [the docs](https://example", "Read the docs"], + ["Image ![preview](/workspace/preview", "Image "], + ])( + "repairs an unsplit live tail and restores raw source on completion: %s", + (source, repaired) => { + expect(streamingDocuments(source, true)).toEqual([repaired]); + expect(streamingDocuments(source, false)).toEqual([source]); + }, + ); + + it("repairs only the live split tail", () => { + expect( + streamingDocuments( + "Settled **source.\n\nSecond paragraph.\n\n`live code", + true, + ), + ).toEqual(["Settled **source.\n\n", "Second paragraph.\n\n`live code`"]); + }); + + it("repairs unfinished formatting after ordinary double-colon text", () => { + const source = "Call Namespace::Method, then **live bold"; + expect(streamingDocuments(source, true)).toEqual([ + "Call Namespace::Method, then **live bold**", + ]); + expect( + streamingDocuments(`Settled.\n\nSecond paragraph.\n\n${source}`, true), + ).toEqual(["Settled.\n\n", `Second paragraph.\n\n${source}**`]); + }); + + it.each([ + "::inline-vis[label", + " ::inline-vis[label", + "> ::inline-vis[label", + "- ::inline-vis[label", + '::inline-vis{file="[draft.html"}', + '::inline-vis{file="a__b.html"}', + '::unknown{title="**source"}', + ])("preserves directive source in a live tail: %s", (source) => { + expect(streamingDocuments(source, true)).toEqual([source]); + }); + + it("resumes repair after a directive moves into the settled prefix", () => { + const source = '::inline-vis{file="[draft.html"}'; + expect(streamingDocuments(`${source}\n\n**live`, true)).toEqual([ + `${source}\n\n**live`, + ]); + expect( + streamingDocuments(`${source}\n\nSecond paragraph.\n\n**live`, true), + ).toEqual([`${source}\n\n`, "Second paragraph.\n\n**live**"]); + }); + + it.each([ + '~~~ts\nconst x = "**text";\n', + '> ~~~ts\n> const x = "**text";\n', + '- ```ts\n const x = "**text";\n', + ])("preserves fenced code verbatim in the live tail: %s", (source) => { + expect(streamingDocuments(source, true)).toEqual([source]); + }); + + it("keeps an open fenced block inside the live tail", () => { + expect( + streamingDocuments( + "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n", + true, + ), + ).toEqual(["Intro.\n\n", "```ts\nconst a = 1;\n\nconst b = 2;\n"]); + }); + + it("returns a single document when no boundary is available or when not streaming", () => { + expect(streamingDocuments("Only one paragraph", true)).toEqual([ + "Only one paragraph", + ]); + expect( + streamingDocuments("Para one.\n\nPara two.\n\nPara three", false), + ).toEqual(["Para one.\n\nPara two.\n\nPara three"]); + }); +}); diff --git a/apps/app/src/components/thread/timeline/streaming-markdown-split.ts b/apps/app/src/components/thread/timeline/streaming-markdown-split.ts index fb226d0b79e..42b29a13ccc 100644 --- a/apps/app/src/components/thread/timeline/streaming-markdown-split.ts +++ b/apps/app/src/components/thread/timeline/streaming-markdown-split.ts @@ -117,3 +117,17 @@ export function splitStreamingMarkdown( tail: text.slice(settledLength), }; } + +export function resolveStreamingMarkdown( + text: string, + streaming: boolean, +): { split: StreamingMarkdownSplit | null; liveMarkdown: string } { + if (!streaming) { + return { split: null, liveMarkdown: text }; + } + const split = splitStreamingMarkdown(text); + return { + split, + liveMarkdown: repairStreamingMarkdownTail(split?.tail ?? text), + }; +} diff --git a/apps/app/src/components/thread/timeline/thread-context-window-usage.test.ts b/apps/app/src/components/thread/timeline/thread-context-window-usage.test.ts new file mode 100644 index 00000000000..c1454b0c0da --- /dev/null +++ b/apps/app/src/components/thread/timeline/thread-context-window-usage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + calculateContextWindowUsagePercent, + formatUsageCost, +} from "./thread-context-window-usage"; + +describe("calculateContextWindowUsagePercent", () => { + it.each([ + { usedTokens: 1000, modelContextWindow: 10000, expected: 10 }, + { usedTokens: 1234, modelContextWindow: 10000, expected: 12 }, + { usedTokens: 15000, modelContextWindow: 10000, expected: 100 }, + { usedTokens: 1000, modelContextWindow: 0, expected: 0 }, + ])( + "reports $usedTokens of $modelContextWindow tokens as $expected%", + ({ usedTokens, modelContextWindow, expected }) => { + expect( + calculateContextWindowUsagePercent({ + usedTokens, + modelContextWindow, + estimated: false, + }), + ).toBe(expected); + }, + ); +}); + +describe("formatUsageCost", () => { + it("formats a reported session cost with two decimals", () => { + expect(formatUsageCost({ amount: 1.5, currency: "USD" })).toMatch(/1\.50/); + }); +}); diff --git a/apps/app/src/components/thread/timeline/thread-ongoing-indicator.test.ts b/apps/app/src/components/thread/timeline/thread-ongoing-indicator.test.ts new file mode 100644 index 00000000000..5a6997097ea --- /dev/null +++ b/apps/app/src/components/thread/timeline/thread-ongoing-indicator.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveThreadOngoingIndicator } from "./thread-ongoing-indicator"; + +const idle = { + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activeWorkflowCount: 0, + displayStatus: "idle", + isStopping: false, + isTurnSubmitting: false, + timelineLoading: false, +} as const; + +describe("resolveThreadOngoingIndicator", () => { + it.each([ + { + name: "a background-only indicator while runtime is idle with a workflow running", + input: { ...idle, activeWorkflowCount: 1 }, + expected: { show: true, label: "Background work running" }, + }, + { + name: "the normal working label while runtime is active", + input: { ...idle, activeWorkflowCount: 1, displayStatus: "active" }, + expected: { show: true, label: undefined }, + }, + { + name: "a background indicator for an idle thread with only a nested agent active", + input: { ...idle, activeBackgroundAgentCount: 1 }, + expected: { show: true, label: "Background work running" }, + }, + { + name: "no indicator once the nested agent count returns to zero", + input: idle, + expected: { show: false, label: undefined }, + }, + ] as const)("shows $name", ({ input, expected }) => { + expect(resolveThreadOngoingIndicator(input)).toEqual(expected); + }); +}); diff --git a/apps/app/src/components/thread/timeline/thread-ongoing-indicator.ts b/apps/app/src/components/thread/timeline/thread-ongoing-indicator.ts new file mode 100644 index 00000000000..52a62445807 --- /dev/null +++ b/apps/app/src/components/thread/timeline/thread-ongoing-indicator.ts @@ -0,0 +1,43 @@ +import { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core"; +import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; + +export function resolveThreadOngoingIndicator({ + activeBackgroundAgentCount, + activeBackgroundCommandCount, + activeWorkflowCount, + displayStatus, + isStopping, + isTurnSubmitting, + timelineLoading, +}: { + activeBackgroundAgentCount: number; + activeBackgroundCommandCount: number; + activeWorkflowCount: number; + displayStatus: ThreadRuntimeDisplayStatus; + isStopping: boolean; + isTurnSubmitting: boolean; + timelineLoading: boolean; +}): { label: string | undefined; show: boolean } { + const isProvisioningDisplayStatus = + displayStatus === "provisioning" || displayStatus === "starting"; + const hasActiveBackgroundWork = + activeWorkflowCount > 0 || + activeBackgroundCommandCount > 0 || + activeBackgroundAgentCount > 0; + const backgroundOnlyIndicatorLabel = + displayStatus === "idle" && hasActiveBackgroundWork + ? "Background work running" + : undefined; + return { + label: isProvisioningDisplayStatus + ? "Provisioning thread..." + : backgroundOnlyIndicatorLabel, + show: + !isStopping && + (isProvisioningDisplayStatus || + (!timelineLoading && + (isTurnSubmitting || + isRunningThreadRuntimeDisplayStatus(displayStatus) || + backgroundOnlyIndicatorLabel !== undefined))), + }; +} diff --git a/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.test.ts b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.test.ts new file mode 100644 index 00000000000..f1999abac25 --- /dev/null +++ b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { + TimelineCommandWorkRow, + TimelineOutputPreview, +} from "@bb/server-contract"; +import { commandRow } from "@/test/fixtures/thread-timeline-rows"; +import { + resolveTimelineWorkRowFullOutput, + shouldLoadTimelineWorkRowFullOutput, +} from "./useTimelineWorkRowFullOutput"; + +const FULL_OUTPUT = `FULL-HEAD ${"x".repeat(5_000)} FULL-TAIL`; +const PREVIEW_OUTPUT = + "FULL-HEAD xxx\n…[4,000 characters omitted from preview]\nxxx FULL-TAIL"; + +function bigCommandRow({ + availability = "available", + output = PREVIEW_OUTPUT, + status = "completed", +}: { + availability?: TimelineOutputPreview["experimental_fullOutputAvailability"]; + output?: string; + status?: TimelineCommandWorkRow["status"]; +} = {}): TimelineCommandWorkRow { + return { + ...commandRow({ + id: "cmd_big", + command: "pnpm test", + output, + sourceSeqStart: 4, + sourceSeqEnd: 7, + threadId: "thr_main", + turnId: "turn_1", + status, + exitCode: status === "pending" ? null : 0, + }), + outputPreview: { + experimental_fullOutputAvailability: availability, + totalChars: FULL_OUTPUT.length, + }, + }; +} + +describe("shouldLoadTimelineWorkRowFullOutput", () => { + it.each([ + { name: "a finished previewed row", row: bigCommandRow(), expected: true }, + { + name: "a running row", + row: bigCommandRow({ status: "pending" }), + expected: false, + }, + { + name: "an already-expired retained output", + row: bigCommandRow({ availability: "retention-expired" }), + expected: false, + }, + ])("loads details for $name: $expected", ({ row, expected }) => { + expect(shouldLoadTimelineWorkRowFullOutput(row)).toBe(expected); + }); +}); + +describe("resolveTimelineWorkRowFullOutput", () => { + it("replaces the preview with the loaded full output", () => { + expect( + resolveTimelineWorkRowFullOutput({ + row: bigCommandRow(), + data: [ + commandRow({ + id: "cmd_big", + command: "pnpm test", + output: FULL_OUTPUT, + threadId: "thr_main", + turnId: "turn_1", + }), + ], + isError: false, + }), + ).toEqual({ output: FULL_OUTPUT, state: "loaded" }); + }); + + it("keeps the live preview for a running row", () => { + expect( + resolveTimelineWorkRowFullOutput({ + row: bigCommandRow({ status: "pending" }), + data: undefined, + isError: false, + }), + ).toEqual({ output: PREVIEW_OUTPUT, state: "streaming-preview" }); + }); + + it("keeps the preview when the full-output load fails", () => { + expect( + resolveTimelineWorkRowFullOutput({ + row: bigCommandRow(), + data: undefined, + isError: true, + }), + ).toEqual({ output: PREVIEW_OUTPUT, state: "error" }); + }); + + it.each([ + { availability: "detail-limit", state: "limited-preview" }, + { availability: "retention-expired", state: "expired-preview" }, + ] as const)( + "shows the detail preview as $state when details report $availability", + ({ availability, state }) => { + const detailPreview = + "FULL-HEAD detail preview\n…[output remains truncated]\nFULL-TAIL"; + expect( + resolveTimelineWorkRowFullOutput({ + row: bigCommandRow(), + data: [bigCommandRow({ availability, output: detailPreview })], + isError: false, + }), + ).toEqual({ output: detailPreview, state }); + }, + ); + + it("keeps an already-expired retained output as an expired preview", () => { + expect( + resolveTimelineWorkRowFullOutput({ + row: bigCommandRow({ availability: "retention-expired" }), + data: undefined, + isError: false, + }), + ).toEqual({ output: PREVIEW_OUTPUT, state: "expired-preview" }); + }); +}); diff --git a/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts index 6b5b9c384ce..dcdaf01d641 100644 --- a/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts +++ b/apps/app/src/components/thread/timeline/useTimelineWorkRowFullOutput.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import type { TimelineCommandWorkRow, TimelineOutputPreview, @@ -71,12 +71,47 @@ export function shouldLoadTimelineWorkRowFullOutput( ); } +export function resolveTimelineWorkRowFullOutput({ + data, + isError, + row, +}: { + data: readonly TimelineRow[] | undefined; + isError: boolean; + row: TimelinePreviewableWorkRow; +}): Omit { + const outputPreview = row.outputPreview; + if (outputPreview === undefined) { + return { output: row.output, state: "complete" }; + } + if ( + outputPreview.experimental_fullOutputAvailability === "retention-expired" + ) { + return { output: row.output, state: "expired-preview" }; + } + const shouldLoad = shouldLoadTimelineWorkRowFullOutput(row); + const match = + shouldLoad && data !== undefined + ? findPreviewableWorkRow(data, row.workKind, row.callId) + : null; + if (match !== null) { + return { + output: match.output, + state: loadedOutputState(match.outputPreview), + }; + } + if (!shouldLoad) { + return { output: row.output, state: "streaming-preview" }; + } + if (isError || data !== undefined) { + return { output: row.output, state: "error" }; + } + return { output: row.output, state: "loading" }; +} + export function useTimelineWorkRowFullOutput( row: TimelinePreviewableWorkRow, ): TimelineWorkRowFullOutput { - const outputPreview = row.outputPreview; - const isPreview = outputPreview !== undefined; - const shouldLoad = shouldLoadTimelineWorkRowFullOutput(row); const { data, isError, refetch } = useThreadTimelineTurnSummaryDetails( { itemId: row.callId, @@ -85,48 +120,13 @@ export function useTimelineWorkRowFullOutput( threadId: row.threadId, turnId: row.turnId ?? "", }, - { enabled: shouldLoad, refetchOnMount: false }, + { + enabled: shouldLoadTimelineWorkRowFullOutput(row), + refetchOnMount: false, + }, ); const retry = useCallback((): void => { void refetch(); }, [refetch]); - const loadedOutput = useMemo((): { - output: string; - outputPreview: TimelineOutputPreview | undefined; - } | null => { - if (!shouldLoad || data === undefined) { - return null; - } - const match = findPreviewableWorkRow(data, row.workKind, row.callId); - if (match === null) { - return null; - } - return { - output: match.output, - outputPreview: match.outputPreview, - }; - }, [data, row.callId, row.workKind, shouldLoad]); - - if (!isPreview) { - return { output: row.output, state: "complete", retry }; - } - if ( - outputPreview.experimental_fullOutputAvailability === "retention-expired" - ) { - return { output: row.output, state: "expired-preview", retry }; - } - if (loadedOutput !== null) { - return { - output: loadedOutput.output, - state: loadedOutputState(loadedOutput.outputPreview), - retry, - }; - } - if (!shouldLoad) { - return { output: row.output, state: "streaming-preview", retry }; - } - if (isError || data !== undefined) { - return { output: row.output, state: "error", retry }; - } - return { output: row.output, state: "loading", retry }; + return { ...resolveTimelineWorkRowFullOutput({ data, isError, row }), retry }; } diff --git a/apps/app/src/components/tools/ResourceSidebars.test.tsx b/apps/app/src/components/tools/ResourceSidebars.test.tsx index cab77dec24a..37a83b55cf4 100644 --- a/apps/app/src/components/tools/ResourceSidebars.test.tsx +++ b/apps/app/src/components/tools/ResourceSidebars.test.tsx @@ -42,17 +42,6 @@ describe("Plugins sidebar", () => { expect(screen.queryByRole("link", { name: "Browse skills" })).toBeNull(); expect(screen.queryByRole("link", { name: "My skills" })).toBeNull(); }); - - it.each([ - ["/plugins", "Browse plugins"], - ["/plugins?view=installed", "Installed plugins"], - ["/plugins/github", "Browse plugins"], - ["/plugins/github?view=installed", "Installed plugins"], - ])("marks %s as %s", (path, expected) => { - renderSidebarAt("plugins", path); - - expect(row(expected).getAttribute("aria-current")).toBe("page"); - }); }); describe("Skills sidebar", () => { @@ -70,16 +59,4 @@ describe("Skills sidebar", () => { screen.queryByRole("link", { name: "Installed plugins" }), ).toBeNull(); }); - - it.each([ - ["/skills", "Browse skills"], - ["/skills/registry", "Browse skills"], - ["/skills?view=library", "My skills"], - ["/skills/library/my-skill", "My skills"], - ["/skills/registry/owner%2Frepo%2Fskill", "Browse skills"], - ])("marks %s as %s", (path, expected) => { - renderSidebarAt("skills", path); - - expect(row(expected).getAttribute("aria-current")).toBe("page"); - }); }); diff --git a/apps/app/src/components/tools/tools-navigation.test.ts b/apps/app/src/components/tools/tools-navigation.test.ts new file mode 100644 index 00000000000..2d594314079 --- /dev/null +++ b/apps/app/src/components/tools/tools-navigation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { resolveToolsActivePage } from "./tools-navigation"; + +describe("resolveToolsActivePage", () => { + it.each([ + ["/plugins", "", "plugins-browse"], + ["/plugins", "?view=installed", "plugins-installed"], + ["/plugins/github", "", "plugins-browse"], + ["/plugins/github", "?view=installed", "plugins-installed"], + ["/skills", "", "skills-browse"], + ["/skills/registry", "", "skills-browse"], + ["/skills", "?view=library", "skills-library"], + ["/skills/library/my-skill", "", "skills-library"], + ["/skills/registry/owner%2Frepo%2Fskill", "", "skills-browse"], + ])("marks %s%s as %s", (pathname, search, expected) => { + expect(resolveToolsActivePage(pathname, search)).toBe(expected); + }); +}); diff --git a/apps/app/src/components/ui/app-toast-runtime.test.ts b/apps/app/src/components/ui/app-toast-runtime.test.ts new file mode 100644 index 00000000000..5535521ca71 --- /dev/null +++ b/apps/app/src/components/ui/app-toast-runtime.test.ts @@ -0,0 +1,20 @@ +import { toast } from "sonner"; +import { beforeEach, expect, it, vi } from "vitest"; + +beforeEach(() => { + vi.resetModules(); +}); + +it("replays a toast fired before the toaster has loaded once it attaches", async () => { + const runtime = await import("./app-toast-runtime"); + const received: unknown[] = []; + + runtime.withSonnerToast((sonnerToast) => received.push(sonnerToast)); + + expect(received).toEqual([]); + expect(runtime.isToasterRequested()).toBe(true); + + runtime.attachSonnerToast(toast); + + expect(received).toEqual([toast]); +}); diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.anchor-class.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.anchor-class.test.tsx index 21d4a024e03..0dfb1a5db3d 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.anchor-class.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.anchor-class.test.tsx @@ -1,33 +1,14 @@ -// @vitest-environment jsdom - -import { cleanup, render } from "@testing-library/react"; import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { BottomAnchoredScrollBody } from "@/components/ui/bottom-anchored-scroll-body"; -class ResizeObserverMock implements ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -beforeEach(() => { - vi.stubGlobal("ResizeObserver", ResizeObserverMock); - vi.stubGlobal( - "requestAnimationFrame", - vi.fn(() => 1), - ); - vi.stubGlobal("cancelAnimationFrame", vi.fn()); -}); - afterEach(() => { - cleanup(); vi.unstubAllGlobals(); - vi.restoreAllMocks(); }); -function renderBody(scrollOverlay?: ReactNode) { - const view = render( +function renderBody(scrollOverlay?: ReactNode): string { + return renderToStaticMarkup( Footer
} maxWidthClassName="max-w-none" @@ -37,13 +18,10 @@ function renderBody(scrollOverlay?: ReactNode) {
row-a
, ); - const row = view.container.querySelector('[data-timeline-row-id="row-a"]'); - const contentWrapper = row?.parentElement; - const sentinel = view.container.querySelector(".scroll-bottom-anchor"); - if (!contentWrapper || !sentinel) { - throw new Error("Scroll body did not render its wrapper and sentinel"); - } - return { container: view.container, contentWrapper, sentinel }; +} + +function classTokens(className: string | undefined): string[] { + return className?.split(/\s+/) ?? []; } describe("BottomAnchoredScrollBody scroll-anchor exclusion", () => { @@ -52,47 +30,48 @@ describe("BottomAnchoredScrollBody scroll-anchor exclusion", () => { supports: (property: string, value: string) => property === "overflow-anchor" && value === "none", }); - const { container, contentWrapper, sentinel } = renderBody(); + const markup = renderBody(); + const structure = markup.match( + /
]*>
row-a<\/div><\/div>