diff --git a/AGENTS.md b/AGENTS.md index 8bfc5fd17d..d9e2f87f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ - Only write high quality tests that verify where there could be potential bugs. Avoid testing trivial getters/setters, framework wiring, or other code that is unlikely to break. - Pipe slow test output to a file, then read the file. Example: `pnpm exec turbo run test --filter=@bb/integration-tests --force > /tmp/test-out.txt 2>&1`. +- Package `vitest.config.ts` files build their `projects` with `sharedWorkerProjects` from `vitest.shared.ts`. It runs node-environment test files in shared workers (`isolate: false`) and gives a file its own worker when it runs in a DOM environment (`jsdom`) or when the file, or a test helper it imports, mutates worker-global state (`vi.mock`, `vi.stubGlobal`, `process.env`, `globalThis.*` assignments, `Object.defineProperty` on a global). Re-importing the module graph per file was 80–90% of the big suites' CPU. Restore what a test changes anyway; the scan is a safety net, not a license. ## GitHub Issues And Pull Requests diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 3082049898..4a1f12347f 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -1,5 +1,8 @@ import path from "path"; -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { sharedUiEnvSeam } from "./vite-shared-ui-seam.js"; @@ -14,8 +17,16 @@ export default defineWorkspaceTestConfig({ test: { silent: "passed-only", environment: "node", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], setupFiles: ["src/test/setup.ts"], testTimeout: 15_000, + // Per-file module-graph import and setup were ~85% of this suite's CPU. + // Node-environment files that do not mock share a worker context; jsdom + // files keep their own worker (see vitest.shared.ts). + projects: sharedWorkerProjects({ + pkgDir: __dirname, + aliases: { "@": path.resolve(__dirname, "./src") }, + name: "@bb/app", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }), }, }); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 3f13ba1a19..3649ce826d 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,13 +1,19 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/cli", - exclude: ["dist/**", "node_modules/**"], env: { BB_SERVER_URL: "http://127.0.0.1:49161", BB_HOST_DAEMON_PORT: "49162", }, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/cli", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index a479ab4b02..1135e8b001 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -1,9 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { environment: "node", - include: ["test/**/*.test.ts"], - name: "@bb/desktop", + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/desktop", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/apps/host-daemon/vitest.config.ts b/apps/host-daemon/vitest.config.ts index 9b7bdc85de..c92035d1f8 100644 --- a/apps/host-daemon/vitest.config.ts +++ b/apps/host-daemon/vitest.config.ts @@ -1,16 +1,21 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/host-daemon", - include: ["src/**/*.test.ts", "test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], env: { BB_DATA_DIR: "/tmp/bb-host-daemon-test", BB_SERVER_URL: "http://127.0.0.1:49161", BB_HOST_DAEMON_PORT: "49162", }, testTimeout: 15_000, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/host-daemon", + include: ["src/**/*.test.ts", "test/**/*.test.ts"], + }), }, }); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index b798123a6d..25422dbbaa 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; // Pure-logic tests only (node environment). Screen behavior is covered by // Maestro flows under e2e/flows. Modules under test must not import @@ -13,8 +16,13 @@ export default defineWorkspaceTestConfig({ test: { silent: "passed-only", environment: "node", - include: ["src/**/*.test.ts"], passWithNoTests: true, testTimeout: 15_000, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + aliases: { "@": path.resolve(__dirname, "./src") }, + name: "@bb/mobile", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/apps/server/test/helpers/provider-registry.ts b/apps/server/test/helpers/provider-registry.ts index 90e5280b86..d70f512c76 100644 --- a/apps/server/test/helpers/provider-registry.ts +++ b/apps/server/test/helpers/provider-registry.ts @@ -177,34 +177,55 @@ export function stubHostArtifact(pluginId: string): PluginHostArtifactSnapshot { }; } +const firstPartyBridgeArtifactBuilds = new Map< + string, + Promise +>(); + +async function buildFirstPartyBridgeArtifact( + pluginId: string, +): Promise { + const rootDir = pluginRootDir(pluginId); + // Pi has no `bb.host`: its bridge stays in the daemon bundle. + if (!(await hasHostEntry(rootDir))) { + return null; + } + const toolchain = await resolvePluginBuildToolchain( + join(tmpdir(), "bb-plugin-build-toolchain"), + ); + const build = await buildPluginHost(rootDir, "0.0.0-test", toolchain); + const bytes = await readFile(build.jsPath); + return { + digest: build.artifactDigest, + byteLength: bytes.byteLength, + path: build.jsPath, + generation: `test-${pluginId}`, + }; +} + /** * Builds and records the first-party provider bridge artifacts, exactly as the * plugin runtime does on load. Without this a graduated provider has no * `bridgeLaunch`, so the daemon has no bridge for it at all — which is the * whole point of the artifact route and therefore worth exercising rather * than stubbing. Bridges are rebuilt from source so a stale `dist/` cannot - * make a test pass against yesterday's bridge. + * make a test pass against yesterday's bridge — once per worker process: + * the sources do not change during a run, and the ~0.6s esbuild pass was + * paid by every integration harness, one per test. */ export async function recordFirstPartyProviderBridgeArtifacts( artifacts: PluginHostArtifactRegistry, ): Promise { - const toolchain = await resolvePluginBuildToolchain( - join(tmpdir(), "bb-plugin-build-toolchain"), - ); for (const pluginId of FIRST_PARTY_PROVIDER_PLUGIN_IDS) { - const rootDir = pluginRootDir(pluginId); - // Pi has no `bb.host`: its bridge stays in the daemon bundle. - if (!(await hasHostEntry(rootDir))) { - continue; + let build = firstPartyBridgeArtifactBuilds.get(pluginId); + if (!build) { + build = buildFirstPartyBridgeArtifact(pluginId); + firstPartyBridgeArtifactBuilds.set(pluginId, build); + } + const snapshot = await build; + if (snapshot !== null) { + artifacts.set(pluginId, snapshot); } - const build = await buildPluginHost(rootDir, "0.0.0-test", toolchain); - const bytes = await readFile(build.jsPath); - artifacts.set(pluginId, { - digest: build.artifactDigest, - byteLength: bytes.byteLength, - path: build.jsPath, - generation: `test-${pluginId}`, - }); } } diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 7189968047..912d2b3da8 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { AddressInfo } from "node:net"; -import type { DbConnection } from "@bb/db"; +import { createConnection, type DbConnection } from "@bb/db"; import { defaultFeatureFlags, type HostType } from "@bb/domain"; import { initDb } from "../../src/db.js"; import { createApp } from "../../src/server.js"; @@ -119,6 +119,22 @@ export function createTestDaemonHostKey( }); } +let migratedTemplate: Buffer | null = null; + +/** + * A fresh in-memory database with every migration applied and the personal + * project seeded, exactly as `initDb` leaves it. The first call migrates for + * real and keeps the serialized image; every later call opens an independent + * copy of that image. Replaying the 100+ migrations was ~57ms of the ~61ms a + * harness cost, paid by nearly two thousand tests. + */ +export function createTestDb(): DbConnection { + if (migratedTemplate === null) { + migratedTemplate = initDb(":memory:").$client.serialize(); + } + return createConnection(migratedTemplate); +} + export async function createTestAppHarness( overrides: TestAppHarnessConfigOverrides = {}, ): Promise { @@ -129,7 +145,7 @@ export async function createTestAppHarness( ...configOverrides } = overrides; const dataDir = await mkdtemp(join(tmpdir(), "bb-server-test-")); - const db = initDb(":memory:"); + const db = createTestDb(); const hub = new NotificationHubImpl(); const watchInterests = new WatchInterestCoordinator({ db, hub }); const sharedPorts = new HostSharedPortCoordinator({ db, hub }); diff --git a/apps/server/test/internal/internal-skill-trees.test.ts b/apps/server/test/internal/internal-skill-trees.test.ts index fedeb305a1..3332f8fa3a 100644 --- a/apps/server/test/internal/internal-skill-trees.test.ts +++ b/apps/server/test/internal/internal-skill-trees.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { readSkillTreeManifest } from "../../src/services/skills/injected-skills.js"; @@ -13,6 +13,9 @@ describe("internal skill tree routes", () => { const rootPath = path.join(harness.config.dataDir, "tree-route-skill"); await mkdir(rootPath, { recursive: true }); await writeFile(path.join(rootPath, "SKILL.md"), "tree route bytes\n"); + // The manifest reports the on-disk mode; pin it so the process umask + // cannot change the expected entry. + await chmod(path.join(rootPath, "SKILL.md"), 0o644); const manifest = readSkillTreeManifest(rootPath); harness.deps.skillTreeRegistry.register(manifest.treeHash, rootPath); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 8638b2d567..720e91e7c8 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,10 +1,8 @@ import { defineWorkspaceTestConfig, - findIsolationRequiringTests, + sharedWorkerProjects, } from "../../vitest.shared.js"; -const isolationTests = findIsolationRequiringTests(__dirname, ["src", "test"]); - export default defineWorkspaceTestConfig({ test: { silent: "passed-only", @@ -13,23 +11,10 @@ export default defineWorkspaceTestConfig({ BB_SERVER_PORT: "49161", BB_HOST_DAEMON_PORT: "49162", }, - projects: [ - { - extends: true, - test: { - name: "@bb/server", - include: ["src/**/*.test.ts", "test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**", ...isolationTests], - isolate: false, - }, - }, - { - extends: true, - test: { - name: "@bb/server:isolated", - include: isolationTests, - }, - }, - ], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/server", + include: ["src/**/*.test.ts", "test/**/*.test.ts"], + }), }, }); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index d66ff13049..82261e9da9 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,13 +1,16 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ - // vite.config.ts injects this from the target deployment's APP_URL; tests - // load modules without that config, so they get an obviously-not-real origin. define: { __SITE_ORIGIN__: JSON.stringify("https://web.test") }, test: { silent: "passed-only", - name: "@bb/web", - include: ["src/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/web", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts index 78c3094f4c..82f685ded7 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts @@ -1003,37 +1003,66 @@ describe("PiSdkSession", () => { ); }); + /** + * Each transient auth miss waits a real 250ms before the retry; drive the + * clock instead of sleeping through eight of them per test. + */ + async function settleThroughTransientAuthRetries( + pending: Promise, + retries: number, + ): Promise { + for (let i = 0; i < retries; i += 1) { + await vi.advanceTimersByTimeAsync(250); + } + return pending; + } + it("allows eight transient Pi auth storage misses before succeeding", async () => { - const authError = new Error("No API key found for anthropic."); - rejectPromptWithTransientAuthError(8, authError); - mockPrompt.mockResolvedValueOnce(undefined); - const onDone = vi.fn(); - const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); + vi.useFakeTimers(); + try { + const authError = new Error("No API key found for anthropic."); + rejectPromptWithTransientAuthError(8, authError); + mockPrompt.mockResolvedValueOnce(undefined); + const onDone = vi.fn(); + const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); - await session.start(); - await session.prompt("retry after auth storage miss").settled; + await session.start(); + await settleThroughTransientAuthRetries( + session.prompt("retry after auth storage miss").settled, + 8, + ); - expect(mockPrompt).toHaveBeenCalledTimes(9); - expect(onDone).not.toHaveBeenCalled(); + expect(mockPrompt).toHaveBeenCalledTimes(9); + expect(onDone).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it("fails after nine transient Pi auth storage misses", async () => { - const authError = new Error("No API key found for anthropic."); - rejectPromptWithTransientAuthError(9, authError); - const onDone = vi.fn(); - const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); + vi.useFakeTimers(); + try { + const authError = new Error("No API key found for anthropic."); + rejectPromptWithTransientAuthError(9, authError); + const onDone = vi.fn(); + const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); - await session.start(); - const dispatch = session.prompt("fail after retry budget"); - void dispatch.consumed.catch(() => undefined); - await expect(dispatch.settled).resolves.toEqual({ error: authError }); - await expect(dispatch.consumed).rejects.toThrow( - "No API key found for anthropic.", - ); + await session.start(); + const dispatch = session.prompt("fail after retry budget"); + void dispatch.consumed.catch(() => undefined); + await expect( + settleThroughTransientAuthRetries(dispatch.settled, 8), + ).resolves.toEqual({ error: authError }); + await expect(dispatch.consumed).rejects.toThrow( + "No API key found for anthropic.", + ); - expect(mockPrompt).toHaveBeenCalledTimes(9); - expect(onDone).toHaveBeenCalledTimes(1); - expect(onDone).toHaveBeenCalledWith(authError); + expect(mockPrompt).toHaveBeenCalledTimes(9); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledWith(authError); + } finally { + vi.useRealTimers(); + } }); it("stays processing across retryable agent-end events", async () => { diff --git a/packages/agent-runtime/vitest.config.ts b/packages/agent-runtime/vitest.config.ts index 0215da0789..5b50089d6d 100644 --- a/packages/agent-runtime/vitest.config.ts +++ b/packages/agent-runtime/vitest.config.ts @@ -1,11 +1,8 @@ import { defineWorkspaceTestConfig, - findIsolationRequiringTests, + sharedWorkerProjects, } from "../../vitest.shared.js"; -const exclude = ["dist/**", "node_modules/**", "src/integration*.test.ts"]; -const isolationTests = findIsolationRequiringTests(__dirname, ["src"]); - export default defineWorkspaceTestConfig({ test: { silent: "passed-only", @@ -15,24 +12,11 @@ export default defineWorkspaceTestConfig({ // Both projects below extend this root, so the cap applies to each. testTimeout: 15_000, hookTimeout: 15_000, - projects: [ - { - extends: true, - test: { - name: "@bb/agent-runtime", - include: ["src/**/*.test.ts"], - exclude: [...exclude, ...isolationTests], - isolate: false, - }, - }, - { - extends: true, - test: { - name: "@bb/agent-runtime:isolated", - include: isolationTests, - exclude, - }, - }, - ], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/agent-runtime", + include: ["src/**/*.test.ts"], + exclude: ["dist/**", "node_modules/**", "src/integration*.test.ts"], + }), }, }); diff --git a/packages/client-core/vitest.config.ts b/packages/client-core/vitest.config.ts index d5e14c37c3..4bd16a812f 100644 --- a/packages/client-core/vitest.config.ts +++ b/packages/client-core/vitest.config.ts @@ -1,11 +1,16 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/client-core", environment: "node", - include: ["test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/client-core", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/packages/db/src/connection.ts b/packages/db/src/connection.ts index a4d83d2eb6..4c3a6b0c32 100644 --- a/packages/db/src/connection.ts +++ b/packages/db/src/connection.ts @@ -161,11 +161,17 @@ function instrumentSqliteClient( }); } +/** + * Opens the database at `source`. A path (or `:memory:`) opens a file or a + * fresh in-memory database; a `Buffer` opens an in-memory copy of a + * serialized database image (`db.$client.serialize()`), which is how the test + * harnesses clone a migrated template instead of replaying every migration. + */ export function createConnection( - dbPath: string = "bb.db", + source: string | Buffer = "bb.db", options: CreateConnectionOptions = {}, ) { - const sqlite = new Database(dbPath); + const sqlite = new Database(source); // Reclaim freed pages incrementally (via PRAGMA incremental_vacuum in the // periodic maintenance sweep) instead of relying on a full-file VACUUM. This diff --git a/packages/db/test/data/app-settings.test.ts b/packages/db/test/data/app-settings.test.ts index c3c4e223b2..e586c668e2 100644 --- a/packages/db/test/data/app-settings.test.ts +++ b/packages/db/test/data/app-settings.test.ts @@ -1,21 +1,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { defaultAppSettings } from "@bb/domain"; import { - createConnection, getAppKeybindingOverrides, getAppSettings, - migrate, setAppKeybindingOverrides, setAppSettings, type DbConnection, } from "../../src/index.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; describe("app settings data", () => { let db: DbConnection; beforeEach(() => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); }); afterEach(() => { diff --git a/packages/db/test/data/environment-lifecycle.test.ts b/packages/db/test/data/environment-lifecycle.test.ts index 37bea65160..12817b15df 100644 --- a/packages/db/test/data/environment-lifecycle.test.ts +++ b/packages/db/test/data/environment-lifecycle.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { createConnection } from "../../src/connection.js"; import type { DbTransaction } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { environments, threads } from "../../src/schema.js"; @@ -26,10 +24,10 @@ 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/environments.test.ts b/packages/db/test/data/environments.test.ts index 1370d09e52..e4db3bb7e9 100644 --- a/packages/db/test/data/environments.test.ts +++ b/packages/db/test/data/environments.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { @@ -12,10 +10,10 @@ import { } from "../../src/data/environments.js"; import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 3586a97e95..1465b3418a 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -8,8 +8,6 @@ import { turnScope, type PromptInput, } from "@bb/domain"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { @@ -63,10 +61,10 @@ import { createEnvironment } from "../../src/data/environments.js"; 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", @@ -4402,8 +4400,7 @@ describe("events", () => { }); it("lists the latest lifecycle row per open backgroundTask item on a host", () => { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "task-host", type: "persistent", diff --git a/packages/db/test/data/hosts.test.ts b/packages/db/test/data/hosts.test.ts index 49b9ac4c07..36ba3fa47b 100644 --- a/packages/db/test/data/hosts.test.ts +++ b/packages/db/test/data/hosts.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { deleteHost, @@ -13,10 +11,10 @@ import { updateHost, upsertHost, } from "../../src/data/hosts.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); return { db }; } diff --git a/packages/db/test/data/maintenance.test.ts b/packages/db/test/data/maintenance.test.ts index 6965006502..3d7fc5b878 100644 --- a/packages/db/test/data/maintenance.test.ts +++ b/packages/db/test/data/maintenance.test.ts @@ -23,6 +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"; const TEST_INCREMENTAL_VACUUM_MAX_PAGES = 128; @@ -49,8 +50,7 @@ const TEST_DEFERRED_LEGACY_TABLE_NAMES = [ ]; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "maintenance-host", type: "persistent", diff --git a/packages/db/test/data/pending-interactions.test.ts b/packages/db/test/data/pending-interactions.test.ts index a5372826f8..2577050750 100644 --- a/packages/db/test/data/pending-interactions.test.ts +++ b/packages/db/test/data/pending-interactions.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "vitest"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { createEnvironment } from "../../src/data/environments.js"; import { upsertHost } from "../../src/data/hosts.js"; @@ -15,10 +13,10 @@ import { setPendingInteractionResolved, } from "../../src/data/pending-interactions.js"; import { createThread } from "../../src/data/threads.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/plugin-persistence.test.ts b/packages/db/test/data/plugin-persistence.test.ts index 44fc3441da..37bcdfe63e 100644 --- a/packages/db/test/data/plugin-persistence.test.ts +++ b/packages/db/test/data/plugin-persistence.test.ts @@ -1,24 +1,22 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - createConnection, createPluginArtifact, deletePluginArtifact, deleteInstalledPlugin, getInstalledPluginRegistration, getInstalledPlugin, listPluginArtifacts, - migrate, upsertInstalledPlugin, type DbConnection, } from "../../src/index.js"; import type { UpsertInstalledPluginInput } from "../../src/data/plugins.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; describe("normalized plugin persistence", () => { let db: DbConnection; beforeEach(() => { - db = createConnection(":memory:"); - migrate(db); + db = createMigratedConnection(); }); afterEach(() => db.$client.close()); diff --git a/packages/db/test/data/project-execution-defaults.test.ts b/packages/db/test/data/project-execution-defaults.test.ts index 5491647217..851cfcfce4 100644 --- a/packages/db/test/data/project-execution-defaults.test.ts +++ b/packages/db/test/data/project-execution-defaults.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "vitest"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { deleteProject, createProject } from "../../src/data/projects.js"; import { @@ -8,10 +6,10 @@ import { upsertProjectExecutionDefaults, } from "../../src/data/project-execution-defaults.js"; import { upsertHost } from "../../src/data/hosts.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "defaults-host", type: "persistent", diff --git a/packages/db/test/data/project-sources.test.ts b/packages/db/test/data/project-sources.test.ts index fd4cf66802..7436c7d11d 100644 --- a/packages/db/test/data/project-sources.test.ts +++ b/packages/db/test/data/project-sources.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createConnection } from "../../src/connection.js"; import { createProjectSourceId } from "../../src/ids.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { projectSources } from "../../src/schema.js"; import { @@ -17,10 +15,10 @@ 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/projects.test.ts b/packages/db/test/data/projects.test.ts index bc2cf8c9c2..3c6f370adc 100644 --- a/packages/db/test/data/projects.test.ts +++ b/packages/db/test/data/projects.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { createProject, @@ -15,10 +13,10 @@ import { setProjectGitRemoteUrlIfMissing, } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "projects-host", type: "persistent", diff --git a/packages/db/test/data/queued-thread-messages.test.ts b/packages/db/test/data/queued-thread-messages.test.ts index 28c0418f1d..bbc64d5810 100644 --- a/packages/db/test/data/queued-thread-messages.test.ts +++ b/packages/db/test/data/queued-thread-messages.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import type { PromptInput } from "@bb/domain"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { claimNextQueuedThreadMessageGroup, @@ -21,6 +19,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"; function textInput(text: string): PromptInput[] { return [{ type: "text", text, mentions: [] }]; @@ -30,8 +29,7 @@ const defaultInput = textInput("hello"); const altInput = textInput("world"); function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/sessions.test.ts b/packages/db/test/data/sessions.test.ts index 3f70362906..ff1641fd64 100644 --- a/packages/db/test/data/sessions.test.ts +++ b/packages/db/test/data/sessions.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { closeSession, @@ -13,10 +11,10 @@ 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/sweeps.test.ts b/packages/db/test/data/sweeps.test.ts index 251e11bd8a..081af51eb0 100644 --- a/packages/db/test/data/sweeps.test.ts +++ b/packages/db/test/data/sweeps.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { createConnection } from "../../src/connection.js"; import type { DbConnection } from "../../src/connection.js"; import { createEventId } from "../../src/ids.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { @@ -31,10 +29,10 @@ import { events, hostDaemonSessions, } from "../../src/schema.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/terminal-sessions.test.ts b/packages/db/test/data/terminal-sessions.test.ts index eff92cb201..634dee472d 100644 --- a/packages/db/test/data/terminal-sessions.test.ts +++ b/packages/db/test/data/terminal-sessions.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { createTerminalSession, @@ -14,6 +13,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"; type TestDb = ReturnType; type TestHost = ReturnType; @@ -188,8 +188,7 @@ function openTestSession(db: TestDb, hostId: string): TestSession { } function setup(): TerminalSessionFixture { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/thread-lifecycle.test.ts b/packages/db/test/data/thread-lifecycle.test.ts index 1e6068cd3e..adefa12edf 100644 --- a/packages/db/test/data/thread-lifecycle.test.ts +++ b/packages/db/test/data/thread-lifecycle.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { createConnection } from "../../src/connection.js"; import type { DbTransaction } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { threads } from "../../src/schema.js"; import { @@ -17,10 +15,10 @@ 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/thread-search.test.ts b/packages/db/test/data/thread-search.test.ts index 74691e6f59..7c35a92183 100644 --- a/packages/db/test/data/thread-search.test.ts +++ b/packages/db/test/data/thread-search.test.ts @@ -8,7 +8,6 @@ import { type PromptInput, } from "@bb/domain"; import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import { appendStoredThreadEvent, @@ -24,6 +23,7 @@ import { updateThread, upsertThreadSearchSegments, } from "../../src/data/threads.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; interface SetupResult { db: ReturnType; @@ -31,8 +31,7 @@ interface SetupResult { } function setup(): SetupResult { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 6e48cc226c..69349b53fd 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { isRawThreadId } from "@bb/domain"; import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { @@ -45,10 +44,10 @@ import { } 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"; function setup() { - const db = createConnection(":memory:"); - migrate(db); + const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", type: "persistent", diff --git a/packages/db/test/helpers/migrated-connection.ts b/packages/db/test/helpers/migrated-connection.ts new file mode 100644 index 0000000000..6c8136d2df --- /dev/null +++ b/packages/db/test/helpers/migrated-connection.ts @@ -0,0 +1,22 @@ +import { createConnection, migrate } from "../../src/index.js"; +import type { DbConnection } from "../../src/index.js"; + +let migratedTemplate: Buffer | null = null; + +/** + * A fresh in-memory database with every migration applied, exactly as + * `createConnection(":memory:")` followed by `migrate(db)` leaves it. The + * first call migrates for real and keeps the serialized image; every later + * call opens an independent copy of that image. Replaying the 100+ + * migrations costs ~57ms, which the data suites paid once per test. + * + * Suites that exercise `migrate` itself keep calling it directly. + */ +export function createMigratedConnection(): DbConnection { + if (migratedTemplate === null) { + const db = createConnection(":memory:"); + migrate(db); + migratedTemplate = db.$client.serialize(); + } + return createConnection(migratedTemplate); +} diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts index 941f66bbdf..0a98abf966 100644 --- a/packages/db/vitest.config.ts +++ b/packages/db/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/db", - include: ["test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/db", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/packages/domain/vitest.config.ts b/packages/domain/vitest.config.ts index 58e36e1927..c876b0fbad 100644 --- a/packages/domain/vitest.config.ts +++ b/packages/domain/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/domain", - include: ["test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/domain", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/packages/host-workspace/vitest.config.ts b/packages/host-workspace/vitest.config.ts index b910540e27..7bf9defcc5 100644 --- a/packages/host-workspace/vitest.config.ts +++ b/packages/host-workspace/vitest.config.ts @@ -1,15 +1,16 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/host-workspace", - include: ["test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], - // Several tests drive real git subprocesses (concurrent reset/checkout, - // stash) that run fast in isolation but can exceed the 5s default under - // full-suite CPU contention. Match the 15s used by other subprocess-heavy - // packages (@bb/host-daemon, @bb/app, @bb/logger). testTimeout: 15_000, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/host-workspace", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/packages/plugin-build/vitest.config.ts b/packages/plugin-build/vitest.config.ts index fc6fc9075b..5c7dfed678 100644 --- a/packages/plugin-build/vitest.config.ts +++ b/packages/plugin-build/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/plugin-build", - include: ["src/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/plugin-build", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/packages/plugin-sdk/vitest.config.ts b/packages/plugin-sdk/vitest.config.ts index 68738e02fc..f7e0361dfd 100644 --- a/packages/plugin-sdk/vitest.config.ts +++ b/packages/plugin-sdk/vitest.config.ts @@ -1,15 +1,20 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@get-bb/plugin-sdk", - include: [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - // Build/release scripts are plain .mjs and live outside src. - "scripts/**/*.test.mjs", - ], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@get-bb/plugin-sdk", + include: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + // Build/release scripts are plain .mjs and live outside src. + "scripts/**/*.test.mjs", + ], + }), }, }); diff --git a/packages/provider-bridge-protocol/vitest.config.ts b/packages/provider-bridge-protocol/vitest.config.ts index 462991f13c..c5034860e2 100644 --- a/packages/provider-bridge-protocol/vitest.config.ts +++ b/packages/provider-bridge-protocol/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/provider-bridge-protocol", - include: ["src/**/*.test.ts", "test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/provider-bridge-protocol", + include: ["src/**/*.test.ts", "test/**/*.test.ts"], + }), }, }); diff --git a/packages/provider-parity/vitest.config.ts b/packages/provider-parity/vitest.config.ts index b7b8d67278..84fbf0b3df 100644 --- a/packages/provider-parity/vitest.config.ts +++ b/packages/provider-parity/vitest.config.ts @@ -6,5 +6,10 @@ export default defineWorkspaceTestConfig({ name: "@bb/provider-parity", include: ["src/**/*.test.ts"], exclude: ["dist/**", "node_modules/**"], + // Every replay cell is its own bridge child that mostly waits on + // settle/drain pacing, so the suite is wall-clock bound by how many are + // in flight. Pinned to 4 CPUs, 43 cells took 37s at vitest's default of + // 5, 21s at 10, and 15s at 16, all green. + maxConcurrency: 16, }, }); diff --git a/packages/scripts/vitest.config.ts b/packages/scripts/vitest.config.ts index 999c160037..4abea6a543 100644 --- a/packages/scripts/vitest.config.ts +++ b/packages/scripts/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/scripts", - include: ["test/**/*.test.ts", "test/**/*.test.mjs"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/scripts", + include: ["test/**/*.test.ts", "test/**/*.test.mjs"], + }), }, }); diff --git a/packages/templates/test/plugin-scaffold-external.test.ts b/packages/templates/test/plugin-scaffold-external.test.ts index 96648df6da..15d016fbc2 100644 --- a/packages/templates/test/plugin-scaffold-external.test.ts +++ b/packages/templates/test/plugin-scaffold-external.test.ts @@ -268,6 +268,11 @@ async function installPackedSdk( "--legacy-peer-deps", "--no-package-lock", "--no-save", + // Registry round trips the install does not need: the audit and the + // funding banner, and a metadata refresh for packages the cache holds. + "--no-audit", + "--no-fund", + "--prefer-offline", tarball, ], { cwd: targetDir }, @@ -327,10 +332,33 @@ describe("external plugin scaffold types", () => { let workDir: string; let packRoot: string; let tarball: string; + let installedNodeModules: string; + + /** + * Point a scaffold's `node_modules` at the one real install made in + * `beforeAll`: the packed SDK, its registry dependencies, and the linked + * workspace copies. Each of the installs this file used to run cost + * ~13s cold, and every scaffold here resolves the same packages. + */ + async function useInstalledNodeModules(targetDir: string): Promise { + await symlink(installedNodeModules, join(targetDir, "node_modules"), "dir"); + } beforeAll(async () => { packRoot = await mkdtemp(join(tmpdir(), "bb-external-pack-")); tarball = await packPluginSdk(join(packRoot, "pack")); + // The app scaffold declares the superset of the backend scaffold's + // dependencies, so its install serves both. + const templateDir = join(packRoot, "template"); + await scaffoldPlugin({ + targetDir: templateDir, + packageName: "bb-plugin-external-template", + bbVersion: "0.9.0", + app: true, + }); + await installPackedSdk(templateDir, tarball); + await linkExternalDependencies(templateDir); + installedNodeModules = join(templateDir, "node_modules"); }, 180_000); afterAll(async () => { @@ -357,8 +385,7 @@ describe("external plugin scaffold types", () => { await writeFile(join(targetDir, "app.tsx"), REPRESENTATIVE_APP); // The scaffold's own pin, satisfied by the packed artifact. expect(await scaffoldSdkPin(targetDir)).toBe(PLUGIN_SDK_VERSION); - await installPackedSdk(targetDir, tarball); - await linkExternalDependencies(targetDir); + await useInstalledNodeModules(targetDir); const tsconfig = JSON.parse( await readFile(join(targetDir, "tsconfig.json"), "utf8"), @@ -416,8 +443,7 @@ describe("external plugin scaffold types", () => { packageName: "bb-plugin-external-backend", bbVersion: "0.9.0", }); - await installPackedSdk(backendDir, tarball); - await linkExternalDependencies(backendDir); + await useInstalledNodeModules(backendDir); await writeFile(join(backendDir, "server.test.ts"), BACKEND_TEST); await includeTestsInTypecheck(backendDir); @@ -490,8 +516,6 @@ describe("external plugin scaffold types", () => { // No mapping to fall back on: the testing declarations import the package // root, which has to resolve through the install alone. expect(backendTsconfig.compilerOptions.paths).toBeUndefined(); - await runTypecheck(backendDir); - await runVitest(backendDir); const frontendDir = join(workDir, "bb-plugin-external-frontend"); await scaffoldPlugin({ @@ -500,19 +524,18 @@ describe("external plugin scaffold types", () => { bbVersion: "0.9.0", app: true, }); - const frontendSdk = join( - frontendDir, - "node_modules", - "@get-bb", - "plugin-sdk", - ); - await mkdir(dirname(frontendSdk), { recursive: true }); - await symlink(installedSdk, frontendSdk, "dir"); - await linkExternalDependencies(frontendDir); + await useInstalledNodeModules(frontendDir); await writeFile(join(frontendDir, "app.test.tsx"), FRONTEND_TEST); await writeFile(join(frontendDir, "vitest.config.ts"), VITEST_CONFIG); await includeTestsInTypecheck(frontendDir); - await runTypecheck(frontendDir); - await runVitest(frontendDir); + + // The two scaffolds are independent; their typechecks (~3.5s each) and + // test runs overlap. + await Promise.all( + [backendDir, frontendDir].map(async (dir) => { + await runTypecheck(dir); + await runVitest(dir); + }), + ); }, 300_000); }); diff --git a/packages/thread-view/vitest.config.ts b/packages/thread-view/vitest.config.ts index 38107c1b38..4e1d04326d 100644 --- a/packages/thread-view/vitest.config.ts +++ b/packages/thread-view/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "@bb/thread-view", - include: ["test/**/*.test.ts"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "@bb/thread-view", + include: ["test/**/*.test.ts"], + }), }, }); diff --git a/plugins/github/vitest.config.ts b/plugins/github/vitest.config.ts index b0119f903d..05b11f76a7 100644 --- a/plugins/github/vitest.config.ts +++ b/plugins/github/vitest.config.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ resolve: { @@ -9,8 +12,12 @@ export default defineWorkspaceTestConfig({ }, test: { silent: "passed-only", - name: "bb-plugin-github", - include: ["**/*.test.{ts,tsx}"], - exclude: ["node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: import.meta.dirname, + aliases: { "@": path.resolve(import.meta.dirname, ".") }, + name: "bb-plugin-github", + include: ["**/*.test.{ts,tsx}"], + exclude: ["node_modules/**"], + }), }, }); diff --git a/plugins/provider-acp/vitest.config.ts b/plugins/provider-acp/vitest.config.ts index 59659ca205..bf0d34c3dc 100644 --- a/plugins/provider-acp/vitest.config.ts +++ b/plugins/provider-acp/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "bb-plugin-provider-acp", - include: ["src/**/*.test.ts"], - exclude: ["node_modules/**", "dist/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-provider-acp", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/plugins/provider-claude-code/vitest.config.ts b/plugins/provider-claude-code/vitest.config.ts index e5e9131388..198e21631e 100644 --- a/plugins/provider-claude-code/vitest.config.ts +++ b/plugins/provider-claude-code/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "bb-plugin-provider-claude-code", - include: ["src/**/*.test.ts"], - exclude: ["node_modules/**", "dist/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-provider-claude-code", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/plugins/provider-codex/vitest.config.ts b/plugins/provider-codex/vitest.config.ts index 2b010b254e..8e7f74ed7c 100644 --- a/plugins/provider-codex/vitest.config.ts +++ b/plugins/provider-codex/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "bb-plugin-provider-codex", - include: ["src/**/*.test.ts"], - exclude: ["node_modules/**", "dist/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-provider-codex", + include: ["src/**/*.test.ts"], + }), }, }); diff --git a/plugins/tasks/vitest.config.ts b/plugins/tasks/vitest.config.ts index d718182340..88cdfceba3 100644 --- a/plugins/tasks/vitest.config.ts +++ b/plugins/tasks/vitest.config.ts @@ -1,4 +1,7 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ resolve: { @@ -10,13 +13,10 @@ export default defineWorkspaceTestConfig({ }, test: { silent: "passed-only", - name: "bb-plugin-tasks", // CI runners are slow enough that testing-library's default 1s findBy*/ // waitFor timeout flakes on the heavier UI suites (pager, manage, rail). testTimeout: 20_000, setupFiles: ["./vitest.setup.ts"], - include: ["**/*.test.{ts,tsx}"], - exclude: ["node_modules/**"], server: { deps: { // Inlined so the tippy.js alias above applies to its import; left @@ -25,5 +25,11 @@ export default defineWorkspaceTestConfig({ inline: ["@tiptap/extension-bubble-menu"], }, }, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-tasks", + include: ["**/*.test.{ts,tsx}"], + exclude: ["node_modules/**"], + }), }, }); diff --git a/plugins/workflows/vitest.config.ts b/plugins/workflows/vitest.config.ts index 631695e1f4..4cf4306dae 100644 --- a/plugins/workflows/vitest.config.ts +++ b/plugins/workflows/vitest.config.ts @@ -1,5 +1,16 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ - test: { environment: "node", testTimeout: 15_000 }, + test: { + environment: "node", + testTimeout: 15_000, + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-workflows", + include: ["src/**/*.test.{ts,tsx}"], + }), + }, }); diff --git a/tests/integration/vitest.config.ts b/tests/integration/vitest.config.ts index aa8e1dfdb0..1b42554649 100644 --- a/tests/integration/vitest.config.ts +++ b/tests/integration/vitest.config.ts @@ -12,7 +12,7 @@ export default defineWorkspaceTestConfig({ // so we can safely parallelize across files for a large runtime win. fileParallelism: true, // No file here mocks modules or stubs globals/env (vitest.shared.ts's - // findIsolationRequiringTests would flag it), so workers can reuse their + // partitionTestFiles would flag it), so workers can reuse their // context across files instead of re-importing the server graph per file. isolate: false, globalSetup: ["./global-setup.ts"], diff --git a/tests/qa/vitest.config.ts b/tests/qa/vitest.config.ts index c6f15c8074..6a238fea4a 100644 --- a/tests/qa/vitest.config.ts +++ b/tests/qa/vitest.config.ts @@ -4,5 +4,8 @@ export default defineWorkspaceTestConfig({ test: { include: ["test/**/*.test.ts"], name: "@bb/qa", + // These suites spawn real child processes; the 5s default tipped over + // when the whole workspace ran at once. + testTimeout: 15_000, }, }); diff --git a/vitest.config.ts b/vitest.config.ts index cef78a464e..d2f0d1a751 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import { defineConfig } from "vitest/config"; +import { SharedWorkerSequencer } from "./vitest.shared.js"; const workspaceRoots = [ "apps", @@ -26,6 +27,7 @@ function discoverVitestProjects(): string[] { export default defineConfig({ test: { silent: "passed-only", + sequence: { sequencer: SharedWorkerSequencer }, // Keep the default workspace test entrypoint aligned with every package/app // that defines its own Vitest config so new suites are not silently skipped. projects: discoverVitestProjects(), diff --git a/vitest.shared.ts b/vitest.shared.ts index 6539329a7a..4475482a78 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,48 +1,246 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { mergeConfig, type ViteUserConfig } from "vitest/config"; +import { BaseSequencer, type TestSpecification } from "vitest/node"; + +const GLOBAL_OBJECT = String.raw`(?:window|globalThis|global|document|navigator|[A-Z][\w$]*\.prototype)`; +/** A global object, optionally through a TypeScript cast: `(window as X)`. */ +const GLOBAL_TARGET = String.raw`(?:${GLOBAL_OBJECT}|\(\s*${GLOBAL_OBJECT}\s+as\b[^)]*\))`; /** - * Wraps a package's Vitest config so workspace imports (`@bb/*`) resolve to - * package sources instead of built `dist/` output. - * - * Every workspace package's export map carries a `source` condition pointing - * at `src/` — the same condition used by `node --conditions=source` in dev, - * esbuild bundling (`scripts/build-utils.mjs`), and tsc (`customConditions` - * in `packages/tsconfig/typecheck-overrides.json`). Vitest resolves test - * imports through Vite's server environment, which only honors conditions - * under `ssr.resolve`, so a plain `resolve.conditions` entry has no effect on - * tests. Only `source` is listed here: Vitest contributes its own default - * conditions through a config plugin, and Vite concatenates these arrays - * with them during config merge. + * Syntax that mutates worker-global state: the vitest module registry and + * stubs, `process.env`, the working directory, and properties of the global + * objects (`window`, `document`, `navigator`, `globalThis`, prototypes), + * including through a cast such as `(window as X).bbDesktop = ...`. A + * file that contains any of it keeps the default isolated worker, whether or + * not it restores what it changed: in a shared worker (`isolate: false`) a + * missed restore bleeds into the next file, and mocks fail to apply when the + * target module is already loaded. */ +const ISOLATION_REQUIRING_API = new RegExp( + [ + String.raw`\bvi\.(mock|doMock|unmock|doUnmock|resetModules|stubGlobal|stubEnv)\(`, + String.raw`\bprocess\.chdir\(`, + String.raw`\bprocess\.env(\.[A-Za-z_$][\w$]*|\[[^\]]+\])\s*=[^=]`, + String.raw`\bdelete\s+process\.env\b`, + String.raw`\b${GLOBAL_TARGET}\.[A-Za-z_$][\w$.]*\s*=[^=]`, + String.raw`\bdelete\s+${GLOBAL_TARGET}(?![\w$])`, + String.raw`\b(?:Object\.(?:defineProperty|defineProperties|assign)|Reflect\.(?:set|defineProperty|deleteProperty))\(\s*${GLOBAL_TARGET}(?![\w$])`, + ].join("|"), +); + +/** + * Import specifiers in a module: `import x from "..."`, `export ... from + * "..."`, `import "..."`, `import("...")`, and `require("...")`. Only + * relative and aliased specifiers are followed; the rest resolve to `null`. + */ +const IMPORT_SPECIFIER = + /\b(?:import|export)\b[^'"]*?\bfrom\s*["']([^"']+)["']|\bimport\s*\(?\s*["']([^"']+)["']|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g; + +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".mjs", ".cjs", ".js", ".jsx"]; + /** - * Vitest APIs that mutate worker-global state (the module registry, globals, - * or `process.env`). Files calling any of these need their own isolated - * worker; running them with `isolate: false` makes mocks bleed across files - * or silently fail to apply when the target module is already loaded. + * The scan follows a test's local imports only into test-support modules + * (harnesses, helpers, fixtures, mocks): a `vi.mock` in a shared harness + * makes every test that imports it need a fresh module registry. Production + * sources are not followed — a module that sets `process.env` when the app + * boots does so once per worker, exactly as it does once per process in + * production, and following the whole source graph would flag nearly every + * test. */ -const ISOLATION_REQUIRING_API = - /\bvi\.(mock|doMock|unmock|doUnmock|resetModules|stubGlobal|stubEnv)\(/; +const TEST_SUPPORT_DIRS = new Set([ + "test", + "tests", + "__tests__", + "__mocks__", + "__fixtures__", + "fixtures", + "testing", + "test-utils", +]); +const TEST_SUPPORT_FILE = + /(^|[.-])(test|tests|mock|mocks|harness|fixture|fixtures|helpers?)(\.|-|$)/; -const TEST_FILE = /\.test\.tsx?$/; +function isTestSupportModule(relativePath: string): boolean { + const segments = relativePath.split(path.sep); + const baseName = (segments.pop() ?? "").replace(/\.[cm]?[jt]sx?$/, ""); + return ( + segments.some((segment) => TEST_SUPPORT_DIRS.has(segment)) || + TEST_SUPPORT_FILE.test(baseName) + ); +} + +/** + * Every file name shape vitest's default `include` treats as a test. The + * partition below must see every file a package's `include` globs select; + * a file matched by `include` but not by this pattern would run in both + * halves. + */ +const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/; const SKIP_DIRS = new Set(["node_modules", "dist"]); +/** The per-file environment docblock vitest honors. */ +const ENVIRONMENT_DOCBLOCK = /@(?:vitest|jest)-environment\s+([\w-]+)/; + /** - * Finds test files under `roots` (relative to `pkgDir`) that use - * worker-global vitest APIs and therefore must keep the default isolated - * worker. Everything else can run in a shared worker context + * Environments whose files never share a worker. A DOM test mutates the + * shared `document` in ways no source scan can enumerate — portals left in + * `body`, focus, listeners on `window`, module caches keyed by the document + * (media-query lists, drawer stacks) — and running the app's jsdom files + * through one worker failed a different file on every ordering. + */ +const ISOLATED_ENVIRONMENTS = new Set(["jsdom", "happy-dom"]); + +export interface PartitionOptions { + /** + * Import aliases to follow when the scan walks a test file's local + * imports, as alias prefix to directory (absolute or package-relative), + * e.g. `{ "@": "src" }`. + */ + aliases?: Record; + /** + * The root config's `test.environment`, which files without a docblock + * use. Defaults to `node`. A DOM default isolates every such file. + */ + defaultEnvironment?: string; +} + +export interface SharedTestFileGroup { + /** + * The environment the files' docblocks select, or `null` for the files + * that use the project default. + */ + environment: string | null; + files: string[]; +} + +export interface TestFilePartition { + /** + * Files safe to run in a shared worker context, grouped by environment. + * The default-environment group comes first; the rest sort by name. + */ + shared: SharedTestFileGroup[]; + /** + * Files that need their own isolated worker: they mutate worker-global + * state, or they run in a DOM environment. + */ + isolated: string[]; +} + +interface IsolationScan { + pkgDir: string; + aliases: Record; + memo: Map; + visiting: Set; +} + +function isFile(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Resolves a relative or aliased import to a test-support module inside the + * package, mapping the `.js` specifiers TypeScript sources use back to + * `.ts`/`.tsx`. Production sources and anything outside the package resolve + * to `null`. + */ +function resolveLocalImport( + fromFile: string, + specifier: string, + scan: IsolationScan, +): string | null { + let base: string | null = null; + if (specifier.startsWith("./") || specifier.startsWith("../")) { + base = path.resolve(path.dirname(fromFile), specifier); + } else { + for (const [alias, target] of Object.entries(scan.aliases)) { + if (specifier === alias || specifier.startsWith(`${alias}/`)) { + base = path.resolve(scan.pkgDir, target, specifier.slice(alias.length + 1)); + break; + } + } + } + if (base === null) return null; + const withoutJs = base.replace(/\.[cm]?jsx?$/, ""); + const candidates = [ + base, + ...SOURCE_EXTENSIONS.map((extension) => withoutJs + extension), + ...SOURCE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)), + ]; + for (const candidate of candidates) { + const relative = path.relative(scan.pkgDir, candidate); + if (relative.startsWith("..") || relative.split(path.sep).includes("node_modules")) { + continue; + } + if (!isFile(candidate)) continue; + return isTestSupportModule(relative) ? candidate : null; + } + return null; +} + +/** + * Whether `file` — or any local module it imports, transitively — uses an + * isolation-requiring API. A test helper that calls `vi.mock` makes every + * test that imports it depend on a fresh module registry. + */ +function requiresIsolation(file: string, scan: IsolationScan): boolean { + const memo = scan.memo.get(file); + if (memo !== undefined) return memo; + if (scan.visiting.has(file)) return false; + scan.visiting.add(file); + const source = readFileSync(file, "utf8"); + let result = ISOLATION_REQUIRING_API.test(source); + if (!result) { + for (const match of source.matchAll(IMPORT_SPECIFIER)) { + const specifier = match[1] ?? match[2] ?? match[3]; + if (specifier === undefined) continue; + const target = resolveLocalImport(file, specifier, scan); + if (target !== null && requiresIsolation(target, scan)) { + result = true; + break; + } + } + } + scan.visiting.delete(file); + scan.memo.set(file, result); + return result; +} + +/** + * Walks `roots` (relative to `pkgDir`) and splits the test files found there + * by whether they need an isolated worker: files that, directly or through + * a local test helper, use worker-global APIs, and files that run in a DOM + * environment. Everything else can run in a shared worker context * (`isolate: false`), which skips re-importing the module graph for every - * file — by far the dominant cost of the big suites in CI. + * file — by far the dominant cost of the big suites. * - * Returns package-relative posix paths, usable directly as vitest - * `include`/`exclude` entries. + * Shared files are further grouped by the environment their docblock names. + * Vitest only hands a finished worker the next queued file when that file + * has the same project and environment, so a queue that mixes `node` and + * `jsdom` files churns workers instead of reusing them. + * + * Every path is package-relative posix, usable directly as a vitest + * `include`/`exclude` entry. */ -export function findIsolationRequiringTests( +export function partitionTestFiles( pkgDir: string, roots: string[], -): string[] { - const matches: string[] = []; + options: PartitionOptions = {}, +): TestFilePartition { + const defaultEnvironment = options.defaultEnvironment ?? "node"; + const scan: IsolationScan = { + pkgDir, + aliases: options.aliases ?? {}, + memo: new Map(), + visiting: new Set(), + }; + const sharedByEnvironment = new Map>(); + const isolated = new Set(); const walk = (dir: string) => { let entries; try { @@ -54,27 +252,230 @@ export function findIsolationRequiringTests( const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) walk(fullPath); - } else if ( - TEST_FILE.test(entry.name) && - ISOLATION_REQUIRING_API.test(readFileSync(fullPath, "utf8")) - ) { - matches.push(path.relative(pkgDir, fullPath).split(path.sep).join("/")); + } else if (TEST_FILE.test(entry.name)) { + const relative = path + .relative(pkgDir, fullPath) + .split(path.sep) + .join("/"); + const source = readFileSync(fullPath, "utf8"); + const environment = ENVIRONMENT_DOCBLOCK.exec(source)?.[1] ?? null; + if ( + ISOLATED_ENVIRONMENTS.has(environment ?? defaultEnvironment) || + requiresIsolation(fullPath, scan) + ) { + isolated.add(relative); + } else { + let group = sharedByEnvironment.get(environment); + if (!group) { + group = new Set(); + sharedByEnvironment.set(environment, group); + } + group.add(relative); + } } } }; - for (const root of roots) walk(path.join(pkgDir, root)); - return matches.sort(); + for (const root of new Set(roots)) walk(path.join(pkgDir, root)); + const shared = [...sharedByEnvironment] + .map(([environment, files]) => ({ environment, files: [...files].sort() })) + .sort((a, b) => { + if (a.environment === null) return -1; + if (b.environment === null) return 1; + return a.environment.localeCompare(b.environment); + }); + return { shared, isolated: [...isolated].sort() }; +} + +type TestProjects = NonNullable["projects"]>; + +export interface SharedWorkerProjectsArgs { + /** The package directory, normally the config file's `__dirname`. */ + pkgDir: string; + /** + * The project name. Shared files that select another environment report + * as `${name}:${environment}`; the isolated files as `${name}:isolated`. + */ + name: string; + /** Package-relative globs that select the package's test files. */ + include: string[]; + /** Globs excluded from every project. Defaults to `dist/**` and `node_modules/**`. */ + exclude?: string[]; + /** Import aliases the isolation scan follows; see {@link PartitionOptions}. */ + aliases?: Record; + /** The root config's `test.environment`; see {@link PartitionOptions}. */ + defaultEnvironment?: string; +} + +/** + * Splits a package's tests into projects that extend the package's root + * config: one shared-worker project (`isolate: false`) per non-DOM + * environment for the files {@link partitionTestFiles} finds safe, and an + * isolated project for the rest. Every project keeps the package's own + * `include` globs and excludes the other projects' files, so the split never + * changes which files run — only where. + * + * {@link SharedWorkerSequencer} (installed by {@link defineWorkspaceTestConfig}) + * orders the run queue so the files of one shared project sit together. + * Vitest hands a finished worker the next queued file only when that file + * has the same project and environment, so this ordering is what turns + * `isolate: false` into actual worker reuse. Re-importing the module graph + * for every file is the dominant cost of most suites here (80–90% of total + * CPU for the large ones). + */ +export function sharedWorkerProjects( + args: SharedWorkerProjectsArgs, +): TestProjects { + const exclude = args.exclude ?? ["dist/**", "node_modules/**"]; + const options: PartitionOptions = {}; + if (args.aliases !== undefined) options.aliases = args.aliases; + if (args.defaultEnvironment !== undefined) { + options.defaultEnvironment = args.defaultEnvironment; + } + const partition = partitionTestFiles( + args.pkgDir, + args.include.map(globRoot), + options, + ); + const allFiles = [ + ...partition.shared.flatMap((group) => group.files), + ...partition.isolated, + ]; + if (allFiles.length === 0) { + return [{ extends: true, test: { name: args.name, include: args.include, exclude } }]; + } + const otherFiles = (own: readonly string[]) => { + const ownSet = new Set(own); + return allFiles.filter((file) => !ownSet.has(file)); + }; + const projects: TestProjects = partition.shared.map((group) => ({ + extends: true, + test: { + name: + group.environment === null + ? args.name + : `${args.name}:${group.environment}`, + include: args.include, + exclude: [...exclude, ...otherFiles(group.files)], + isolate: false, + }, + })); + if (partition.isolated.length > 0) { + projects.push({ + extends: true, + test: { + name: `${args.name}:isolated`, + include: args.include, + exclude: [...exclude, ...otherFiles(partition.isolated)], + }, + }); + } + return projects; +} + +/** + * Orders the run queue for worker reuse: isolated files first, then the + * shared-worker projects one after another, keeping vitest's own + * slowest-first order inside each block. + * + * Vitest reuses a finished shared worker only when the file at the head of + * the queue belongs to the same project and environment; otherwise it + * terminates the worker and starts a new one. Interleaved projects therefore + * churn workers, and running the projects as separate phases + * (`sequence.groupOrder`) leaves every worker idle at each phase's tail. + * One queue with contiguous blocks avoids both. Isolated files go first + * because each one pays a full module-graph import, so they are the longest + * tasks and scheduling them early keeps the end of the run short. + */ +export class SharedWorkerSequencer extends BaseSequencer { + override async sort( + files: TestSpecification[], + ): Promise { + const sorted = await super.sort(files); + const rank = (spec: TestSpecification) => + spec.project.config.isolate ? 0 : 1; + return sorted + .map((spec, index) => ({ spec, index })) + .sort( + (a, b) => + rank(a.spec) - rank(b.spec) || + a.spec.project.name.localeCompare(b.spec.project.name) || + a.index - b.index, + ) + .map(({ spec }) => spec); + } } +/** + * The literal directory prefix of a glob: `src/** /*.test.ts` is rooted at + * `src`, and `** /*.test.ts` at the package itself. + */ +function globRoot(glob: string): string { + const segments = glob.split("/"); + const literal: string[] = []; + for (const segment of segments) { + if (/[*?{}[\]]/.test(segment)) break; + literal.push(segment); + } + // A fully literal glob names one file; its root is the containing directory. + if (literal.length === segments.length) literal.pop(); + return literal.length > 0 ? literal.join("/") : "."; +} + +/** + * `@hugeicons/core-free-icons` resolves to a barrel that re-exports 5,122 + * one-icon modules, which Node loads in ~0.4–0.7s — paid by every isolated + * worker, since the shared-ui icon registry imports it. The package also + * ships the same exports as one self-contained minified bundle (its + * `production` entry), which loads in ~45ms. Tests alias the bare specifier + * to that bundle; deep imports are untouched. Resolved from the package + * under test, and skipped when that package cannot see the dependency. + */ +function hugeiconsBundleAlias(): { find: RegExp; replacement: string }[] { + try { + const require = createRequire(path.join(process.cwd(), "package.json")); + const packageJson = require.resolve("@hugeicons/core-free-icons/package.json"); + return [ + { + find: /^@hugeicons\/core-free-icons$/, + replacement: path.join( + path.dirname(packageJson), + "dist", + "esm", + "index.min.js", + ), + }, + ]; + } catch { + return []; + } +} + +/** + * Wraps a package's Vitest config so workspace imports (`@bb/*`) resolve to + * package sources instead of built `dist/` output, installs + * {@link SharedWorkerSequencer}, and applies {@link hugeiconsBundleAlias}. + * + * Every workspace package's export map carries a `source` condition pointing + * at `src/` — the same condition used by `node --conditions=source` in dev, + * esbuild bundling (`scripts/build-utils.mjs`), and tsc (`customConditions` + * in `packages/tsconfig/typecheck-overrides.json`). Vitest resolves test + * imports through Vite's server environment, which only honors conditions + * under `ssr.resolve`, so a plain `resolve.conditions` entry has no effect on + * tests. Only `source` is listed here: Vitest contributes its own default + * conditions through a config plugin, and Vite concatenates these arrays + * with them during config merge. + */ export function defineWorkspaceTestConfig( config: ViteUserConfig, ): ViteUserConfig { return mergeConfig( { resolve: { + alias: hugeiconsBundleAlias(), conditions: ["source"], }, test: { + sequence: { sequencer: SharedWorkerSequencer }, coverage: { provider: "v8", include: ["**/*.{ts,tsx,js,jsx,mjs,cjs}"],