From e4bbbe045fba1aea2d965ca829c8f6786ae92743 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:15:51 -0700 Subject: [PATCH] perf(test): faster local test loop and cold-worktree changed runs (#33421) - test:changed exits before starting Vitest when Git reports no changed files. - Unrecorded tests beyond 100 in scope use Vitest's static import graph instead of all running, so a fresh worktree's first changed run no longer runs the whole suite (patched RecordedModulesResolver; CI does not record, unchanged). - Bun's slow config load was the SQLite import scan statting every unit test; filtered runs now classify only the tests their filters can select, and the scan walks directories instead of fs.globSync. - Silence the native config loader notice on every run. - Zod's test bundle is content-addressed and reused, so a killed run leaks nothing; leaked per-run directories older than 6h are swept. - build:packages skips compiling when package inputs, compiler, and dist are unchanged; test scripts no longer nest pnpm invocations. --- .github/CONTRIBUTING.md | 17 ++- config/patches/vitest@5.0.5.patch | 74 +++++++++++ config/scripts/build-workspace-packages.mjs | 122 ++++++++++++++++++ config/scripts/run-vitest.mjs | 55 ++++---- config/scripts/vitest-changed-precheck.mjs | 46 +++++++ .../scripts/vitest-changed-precheck.test.mjs | 42 ++++++ config/scripts/vitest-node-runtime-files.mjs | 5 +- config/scripts/vitest-run-filters.mjs | 62 +++++++++ config/scripts/vitest-run-filters.test.mjs | 53 ++++++++ .../vitest-sqlite-runtime-cache.test.ts | 9 +- config/scripts/vitest-sqlite-runtime-scan.mjs | 72 +++++++++-- config/scripts/vitest-zod-bundle.mjs | 87 +++++++++++-- config/scripts/vitest-zod-bundle.test.mjs | 26 ++++ config/vitest.config.ts | 5 +- package.json | 6 +- pnpm-lock.yaml | 15 ++- pnpm-workspace.yaml | 1 + 17 files changed, 630 insertions(+), 67 deletions(-) create mode 100644 config/patches/vitest@5.0.5.patch create mode 100644 config/scripts/build-workspace-packages.mjs create mode 100644 config/scripts/vitest-changed-precheck.mjs create mode 100644 config/scripts/vitest-changed-precheck.test.mjs create mode 100644 config/scripts/vitest-run-filters.mjs create mode 100644 config/scripts/vitest-run-filters.test.mjs create mode 100644 config/scripts/vitest-zod-bundle.test.mjs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index efe2053d258..52c1d1dd8be 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -78,14 +78,16 @@ pnpm test src/shared/app-version.test.ts --maxWorkers=2 `test:changed` considers staged, unstaged, and untracked files. `--changed=origin/main` also includes committed changes since the branch's merge base. Update that ref when you need a newer baseline. Both accept the same path filters and options as `pnpm test`. +When Git reports no changed files at all, the launcher exits before starting Vitest. Local runs enable Vitest's [dependency recording](https://vitest.dev/config/experimental#experimental-recorddependencies). Ordinary test runs populate the cache; later changed-file runs read it instead of transforming every test's import graph. Records are separate for the Bun and Node -projects. A test without a record always runs, so a cold cache runs everything in -the requested scope. Start with a narrow path to avoid a full cold run. Keep Vite's -local test cache between runs. Use `--experimental.recordDependencies=false` to -return to Vitest's static import graph. +projects and live in each checkout's `node_modules/.vite`. A test without a record +runs when at most 100 such tests are in scope, which records them. Beyond that, as in +a new worktree, unrecorded tests are selected with Vitest's static import graph +instead of running all of them. Keep Vite's local test cache between runs. Use +`--experimental.recordDependencies=false` to use the static import graph for every test. This is local feedback, not a complete regression check. Vitest records loaded imports, including executed dynamic imports, but cannot infer arbitrary filesystem @@ -94,7 +96,12 @@ for changed fixtures, generated files, CLI entry points, and other indirect inpu After changing imports, run the affected suites before committing those changes so later edits use current records. Configuration, lockfile, patch, and workspace-package changes rerun every test within the requested scope. CI does not enable recording; -the existing CI selection policy is unchanged. +the existing CI selection policy is unchanged. The static graph has the same blind spots +and also misses dynamic imports with computed specifiers, so the first changed-file run +in a new worktree is a little less complete than later ones. + +`pnpm build:packages` (which `pnpm test` runs first) skips compiling workspace packages +when their sources, build script, and compiler are unchanged and `dist` is intact. Vitest also supports [`related --run `](https://vitest.dev/guide/cli#vitest-related) and dependency-aware watch mode. `pnpm test --changed` uses Git to provide the source diff --git a/config/patches/vitest@5.0.5.patch b/config/patches/vitest@5.0.5.patch new file mode 100644 index 00000000000..836147c63a1 --- /dev/null +++ b/config/patches/vitest@5.0.5.patch @@ -0,0 +1,74 @@ +diff --git a/dist/chunks/cli-api.De07GUeT.js b/dist/chunks/cli-api.De07GUeT.js +index ff6e63043ff8fa0b821d4b0b382d1fd998d85dbb..a95172c2a451f615f9472a526d7ed8a670256b47 100644 +--- a/dist/chunks/cli-api.De07GUeT.js ++++ b/dist/chunks/cli-api.De07GUeT.js +@@ -10250,6 +10250,7 @@ function groupFilters(filters) { + * Finds test specifications that loaded any of the changed files during their last recorded run. + * A specification without a record is always affected. + */ ++const UNRECORDED_RUN_LIMIT = 100; + class RecordedModulesResolver { + vitest; + related; +@@ -10267,13 +10268,30 @@ class RecordedModulesResolver { + projectSpecs.push(spec); + } + const affected = /* @__PURE__ */ new Set(); +- for (const [project, projectSpecs] of specsByProject) this.findAffectedInProject(project, projectSpecs).forEach((spec) => affected.add(spec)); ++ const unrecorded = []; ++ for (const [project, projectSpecs] of specsByProject) { ++ const result = this.findAffectedInProject(project, projectSpecs); ++ result.affected.forEach((spec) => affected.add(spec)); ++ unrecorded.push(...result.unrecorded); ++ } ++ // Orca: running a few unrecorded files records them; many (a fresh checkout) use the static graph instead of running everything. ++ const unrecordedAffected = unrecorded.length > UNRECORDED_RUN_LIMIT ? await new AffectedModulesResolver(this.vitest, this.related).resolve(unrecorded) : unrecorded; ++ unrecordedAffected.forEach((spec) => affected.add(spec)); + return specs.filter((spec) => affected.has(spec)); + } + findAffectedInProject(project, specs) { +- if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return specs; ++ if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return { ++ affected: specs, ++ unrecorded: [] ++ }; + const dependencies = this.vitest.cache._dependencies.get(project); +- if (!dependencies) return specs; ++ if (!dependencies) return project.config.globalSetup.length ? { ++ affected: specs, ++ unrecorded: [] ++ } : { ++ affected: [], ++ unrecorded: specs ++ }; + const changed = /* @__PURE__ */ new Set(); + for (const file of this.related) { + const index = dependencies.moduleIndex.get(file); +@@ -10281,13 +10299,23 @@ class RecordedModulesResolver { + } + if (project.config.globalSetup.length) { + const globalSetup = dependencies.globalSetup; +- if (!globalSetup || globalSetup.some((index) => changed.has(index))) return specs; ++ if (!globalSetup || globalSetup.some((index) => changed.has(index))) return { ++ affected: specs, ++ unrecorded: [] ++ }; + } + const root = this.vitest.config.root; +- return specs.filter((spec) => { ++ const affected = []; ++ const unrecorded = []; ++ for (const spec of specs) { + const loaded = dependencies.files.get(getRootRelativePath(root, spec.moduleId)); +- return !loaded || loaded.some((index) => changed.has(index)); +- }); ++ if (!loaded) unrecorded.push(spec); ++ else if (loaded.some((index) => changed.has(index))) affected.push(spec); ++ } ++ return { ++ affected, ++ unrecorded ++ }; + } + dependsOnConfig(configDependencies) { + return !!configDependencies?.some((file) => this.related.includes(file)); diff --git a/config/scripts/build-workspace-packages.mjs b/config/scripts/build-workspace-packages.mjs new file mode 100644 index 00000000000..d4984a459c0 --- /dev/null +++ b/config/scripts/build-workspace-packages.mjs @@ -0,0 +1,122 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + statSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '../..') +const PACKAGES = [{ name: '@orca/process-host', dir: 'src/packages/process-host' }] +// Outputs and build scratch space, not inputs. +const NON_INPUTS = new Set(['dist', 'node_modules', '.dist-build.lock']) +const COMPILER_PACKAGES = ['typescript', 'tsx', '@types/node'] + +function listFiles(directory, base = directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + if (directory === base && (NON_INPUTS.has(entry.name) || entry.name.startsWith('.dist-'))) { + return [] + } + const path = join(directory, entry.name) + return entry.isDirectory() + ? listFiles(path, base) + : [relative(base, path).replaceAll('\\', '/')] + }) +} + +/** Sources, manifest, build script, and the exact installed compiler and Node types. */ +export function hashPackageInputs(packageDir) { + const hash = createHash('sha256') + for (const file of listFiles(packageDir).sort()) { + hash + .update(`${file}\0`) + .update(readFileSync(join(packageDir, file))) + .update('\0') + } + const require = createRequire(join(packageDir, 'package.json')) + for (const name of COMPILER_PACKAGES) { + const manifest = realpathSync(require.resolve(`${name}/package.json`)) + hash.update(`${manifest}\0`).update(readFileSync(manifest)).update('\0') + } + return hash.digest('hex') +} + +/** Size and mtime of every output, so a deleted or edited dist file forces a rebuild. */ +function describeOutputs(packageDir) { + const dist = join(packageDir, 'dist') + try { + return listFiles(dist) + .sort() + .map((file) => { + const { size, mtimeMs } = statSync(join(dist, file)) + return `${file}:${size}:${mtimeMs}` + }) + } catch { + return [] + } +} + +function stampPath(packageDir) { + return join(packageDir, 'node_modules', '.cache', 'orca-dist-stamp.json') +} + +export function isPackageBuildCurrent(packageDir, inputs) { + try { + const stamp = JSON.parse(readFileSync(stampPath(packageDir), 'utf8')) + const outputs = describeOutputs(packageDir) + return ( + stamp.inputs === inputs && + outputs.length > 0 && + JSON.stringify(stamp.outputs) === JSON.stringify(outputs) + ) + } catch { + return false + } +} + +function writeStamp(packageDir, inputs) { + const path = stampPath(packageDir) + mkdirSync(dirname(path), { recursive: true }) + const temporary = `${path}.${process.pid}` + writeFileSync(temporary, JSON.stringify({ inputs, outputs: describeOutputs(packageDir) })) + renameSync(temporary, path) +} + +function runPackageBuild(name) { + // Why not @orca/process-host: this builds it, so its dist may not exist yet. + const pnpmEntry = process.env.npm_execpath + const [program, args] = /\.[cm]?js$/.test(pnpmEntry ?? '') + ? [process.execPath, [pnpmEntry, '--filter', name, 'build']] + : ['pnpm', ['--filter', name, 'build']] + const result = spawnSync(program, args, { + cwd: root, + stdio: 'inherit', + shell: program === 'pnpm' && process.platform === 'win32' + }) + if (result.error) { + throw result.error + } + return result.status ?? 1 +} + +if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) { + for (const { name, dir } of PACKAGES) { + const packageDir = join(root, dir) + // Hashed before building: an edit during the build leaves a stale stamp, never a skipped build. + const inputs = hashPackageInputs(packageDir) + if (isPackageBuildCurrent(packageDir, inputs)) { + continue + } + const status = runPackageBuild(name) + if (status !== 0) { + process.exit(status) + } + writeStamp(packageDir, inputs) + } +} diff --git a/config/scripts/run-vitest.mjs b/config/scripts/run-vitest.mjs index 1091c6ca313..93e6d2c6f54 100644 --- a/config/scripts/run-vitest.mjs +++ b/config/scripts/run-vitest.mjs @@ -1,5 +1,7 @@ import { resolve } from 'node:path' -import { runProcessSync } from '@orca/process-host' +import { runProcess, runProcessSync } from '@orca/process-host' +import { hasNoChangedFiles } from './vitest-changed-precheck.mjs' +import { RUN_ARGS_ENV } from './vitest-run-filters.mjs' import { createVitestZodBundle } from './vitest-zod-bundle.mjs' import { checkBunVersion, @@ -9,43 +11,48 @@ import { } from './pinned-bun-executable.mjs' const root = resolve(import.meta.dirname, '../..') -const pinnedBun = resolvePinnedBunExecutable(root, runProcessSync) -const bunCheck = pinnedBun - ? { ok: true } - : checkBunVersion(probeBunVersion('bun', root, runProcessSync), readPinnedBunVersion(root)) -if (bunCheck.message) { - console.error(bunCheck.message) -} -if (!bunCheck.ok) { - process.exitCode = 1 -} else { - let zodBundle +const vitestArgs = process.argv.slice(2) + +function runVitest() { + const pinnedBun = resolvePinnedBunExecutable(root, runProcessSync) + const bunCheck = pinnedBun + ? { ok: true } + : checkBunVersion(probeBunVersion('bun', root, runProcessSync), readPinnedBunVersion(root)) + if (bunCheck.message) { + console.error(bunCheck.message) + } + if (!bunCheck.ok) { + return 1 + } try { - zodBundle = createVitestZodBundle(root) + const zodBundle = createVitestZodBundle(root) const result = runProcessSync({ program: pinnedBun ?? 'bun', - args: ['--bun', resolve(root, 'node_modules/vitest/vitest.mjs'), ...process.argv.slice(2)], + args: ['--bun', resolve(root, 'node_modules/vitest/vitest.mjs'), ...vitestArgs], cwd: root, env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_TEST_NODE_EXECUTABLE: process.execPath, ORCA_TEST_NODE_VERSION: process.versions.node, - ORCA_UNIT_ZOD_BUNDLE: zodBundle.path + ORCA_UNIT_ZOD_BUNDLE: zodBundle.path, + [RUN_ARGS_ENV]: JSON.stringify(vitestArgs), + // The config's TypeScript and CommonJS package scope are intended; the notice is noise. + VITE_CONFIG_NATIVE_IGNORE_WARNING: 'true' }, stdio: 'inherit', timeoutMs: null }) - - process.exitCode = result.code ?? 1 + return result.code ?? 1 } catch (error) { console.error('Could not prepare or start Vitest:', error) - process.exitCode = 1 - } finally { - try { - zodBundle?.cleanup() - } catch (error) { - console.warn('Could not remove the temporary Vitest Zod bundle:', error) - } + return 1 } } + +if (await hasNoChangedFiles(vitestArgs, root, runProcess)) { + // Vitest's own result for this case, without paying its startup. + console.log('No changed files found, exiting with code 0') +} else { + process.exitCode = runVitest() +} diff --git a/config/scripts/vitest-changed-precheck.mjs b/config/scripts/vitest-changed-precheck.mjs new file mode 100644 index 00000000000..9de1e5bbd88 --- /dev/null +++ b/config/scripts/vitest-changed-precheck.mjs @@ -0,0 +1,46 @@ +const WATCH_ARGS = new Set(['-w', '--watch']) + +/** The `--changed` ref of a one-shot run: true for the bare flag, undefined when absent or ambiguous. */ +export function parseChangedArg(args) { + if (args[0] !== 'run' || args.some((arg) => WATCH_ARGS.has(arg.split('=')[0]))) { + return undefined + } + const index = args.findIndex((arg) => arg === '--changed' || arg.startsWith('--changed=')) + if (index === -1) { + return undefined + } + const arg = args[index] + if (arg !== '--changed') { + return arg.slice('--changed='.length) || undefined + } + // Vitest would read a following positional as the ref; leave that form to Vitest. + const next = args[index + 1] + return next === undefined || next.startsWith('-') ? true : undefined +} + +/** + * Whether Git reports no changed files for Vitest's `--changed`, which then runs nothing, so the + * caller can skip starting it. Any doubt returns false. + */ +export async function hasNoChangedFiles(args, root, runProcess) { + const changed = parseChangedArg(args) + if (changed === undefined) { + return false + } + const queries = [ + // Covers Vitest's staged, modified, and untracked queries. Unlike `ls-files --modified`, it + // refreshes stale index stat data instead of rehashing files on every run. + ['status', '--porcelain', '-z', '--untracked-files=all', '--ignore-submodules=none'], + ...(typeof changed === 'string' ? [['diff', '--name-only', '-z', `${changed}...HEAD`]] : []) + ] + try { + const results = await Promise.all( + queries.map((args) => + runProcess({ program: 'git', args, cwd: root, timeoutMs: 30_000, maxOutputBytes: 4096 }) + ) + ) + return results.every((result) => result.code === 0 && result.stdout === '') + } catch { + return false + } +} diff --git a/config/scripts/vitest-changed-precheck.test.mjs b/config/scripts/vitest-changed-precheck.test.mjs new file mode 100644 index 00000000000..08cc49579ad --- /dev/null +++ b/config/scripts/vitest-changed-precheck.test.mjs @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { hasNoChangedFiles } from './vitest-changed-precheck.mjs' + +const clean = () => vi.fn(async () => ({ code: 0, stdout: '' })) + +describe('hasNoChangedFiles', () => { + it('also checks commits since the ref', async () => { + const runProcess = clean() + await expect( + hasNoChangedFiles(['run', '--changed=origin/main'], '/repo', runProcess) + ).resolves.toBe(true) + expect(runProcess.mock.calls.map(([spec]) => spec.args)).toContainEqual([ + 'diff', + '--name-only', + '-z', + 'origin/main...HEAD' + ]) + }) + + it('starts Vitest when Git reports a file or cannot answer', async () => { + for (const result of [ + { code: 0, stdout: '?? src/a.ts\0' }, + { code: 128, stdout: '' } + ]) { + const runProcess = vi.fn(async () => result) + await expect(hasNoChangedFiles(['run', '--changed'], '/repo', runProcess)).resolves.toBe( + false + ) + } + }) + + it.each([ + [['run', 'src/a']], + [['run', '--changed', 'src/a']], + [['run', '--changed', '--watch']], + [['related', '--changed']] + ])('leaves %j to Vitest', async (args) => { + const runProcess = clean() + await expect(hasNoChangedFiles(args, '/repo', runProcess)).resolves.toBe(false) + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 1e0c59d4323..e9a63a54764 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -197,6 +197,7 @@ const NODE_RUNTIME_INCLUDE = [ 'config/scripts/vitest-node-runtime-pool.test.ts' ] -export function getNodeRuntimeInclude() { - return [...NODE_RUNTIME_INCLUDE, ...findSqliteRuntimeTests()] +/** `filters` limits the SQLite import scan to the tests a filtered CLI run can select. */ +export function getNodeRuntimeInclude({ filters } = {}) { + return [...NODE_RUNTIME_INCLUDE, ...findSqliteRuntimeTests({ filters })] } diff --git a/config/scripts/vitest-run-filters.mjs b/config/scripts/vitest-run-filters.mjs new file mode 100644 index 00000000000..f51198a31e3 --- /dev/null +++ b/config/scripts/vitest-run-filters.mjs @@ -0,0 +1,62 @@ +// Options that always consume the next argument. Anything else is treated as a flag, so an +// unlisted option's value becomes an extra filter: a wider scan, never a missed file. +const VALUE_OPTIONS = new Set([ + '-c', + '--config', + '--maxWorkers', + '--project', + '--reporter', + '--outputFile', + '--shard', + '--pool', + '--environment', + '-t', + '--testNamePattern', + '--testTimeout', + '--hookTimeout', + '--retry', + '--bail', + '--sequence.seed' +]) +// Modes where Vitest can select files beyond the command-line filters. +const UNSCOPED_OPTIONS = new Set(['-w', '--watch', '--dir', '-r', '--root', '--related']) + +export const RUN_ARGS_ENV = 'ORCA_VITEST_RUN_ARGS' + +/** Positional filters of a one-shot `vitest run`, or undefined when it may select any file. */ +export function parseRunFilters(args) { + if (args[0] !== 'run') { + return undefined + } + const filters = [] + for (let index = 1; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--') { + return undefined + } + if (!arg.startsWith('-')) { + filters.push(arg) + continue + } + const name = arg.split('=')[0] + if (UNSCOPED_OPTIONS.has(name)) { + return undefined + } + if (!arg.includes('=') && VALUE_OPTIONS.has(name)) { + index += 1 + } + } + return filters.length ? filters : undefined +} + +/** + * Filters of the Vitest CLI run that run-vitest.mjs launched in this process. The argv check + * keeps a nested Vitest that inherits the environment from using its parent's filters. + */ +export function readRunFilters(env = process.env, argv = process.argv) { + const launched = env[RUN_ARGS_ENV] + if (!launched || launched !== JSON.stringify(argv.slice(2))) { + return undefined + } + return parseRunFilters(argv.slice(2)) +} diff --git a/config/scripts/vitest-run-filters.test.mjs b/config/scripts/vitest-run-filters.test.mjs new file mode 100644 index 00000000000..36f99cbd332 --- /dev/null +++ b/config/scripts/vitest-run-filters.test.mjs @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { parseRunFilters, readRunFilters, RUN_ARGS_ENV } from './vitest-run-filters.mjs' +import { matchesRunFilter } from './vitest-sqlite-runtime-scan.mjs' + +describe('parseRunFilters', () => { + it('returns the positional filters of a one-shot run', () => { + expect( + parseRunFilters([ + 'run', + '--config', + 'config/vitest.config.ts', + 'src/main/runtime', + '--maxWorkers=2', + '-t', + 'name', + 'src/shared/a.test.ts:12' + ]) + ).toEqual(['src/main/runtime', 'src/shared/a.test.ts:12']) + }) + + it('treats an unknown option value as a filter, widening rather than narrowing the scan', () => { + expect(parseRunFilters(['run', '--changed', 'src/a', '--unknown', '7'])).toEqual(['src/a', '7']) + }) + + it.each([ + [['run', '--config', 'x']], + [['watch', 'src/a']], + [['related', 'src/a.ts']], + [['run', '--watch', 'src/a']], + [['run', '--dir', 'src', 'a']], + [['run', 'src/a', '--', 'b']] + ])('scans every file for %j', (args) => { + expect(parseRunFilters(args)).toBeUndefined() + }) + + it('ignores inherited arguments that this process was not launched with', () => { + const args = ['run', 'src/a'] + const env = { [RUN_ARGS_ENV]: JSON.stringify(args) } + expect(readRunFilters(env, ['bun', 'vitest.mjs', ...args])).toEqual(['src/a']) + expect(readRunFilters(env, ['bun', 'vitest.mjs', 'run', 'src/b'])).toBeUndefined() + }) +}) + +describe('matchesRunFilter', () => { + it('matches every file a Vitest substring or line filter selects', () => { + const file = 'src/shared/worktree/Branch-Name.test.ts' + expect(matchesRunFilter(file, ['src/shared/worktree'])).toBe(true) + expect(matchesRunFilter(file, ['branch-name'])).toBe(true) + expect(matchesRunFilter(file, [`${file}:12`])).toBe(true) + expect(matchesRunFilter(file, [`${process.cwd()}/src/shared/`])).toBe(true) + expect(matchesRunFilter(file, ['src/main'])).toBe(false) + }) +}) diff --git a/config/scripts/vitest-sqlite-runtime-cache.test.ts b/config/scripts/vitest-sqlite-runtime-cache.test.ts index e41f5ff699e..e6ea05457fb 100644 --- a/config/scripts/vitest-sqlite-runtime-cache.test.ts +++ b/config/scripts/vitest-sqlite-runtime-cache.test.ts @@ -7,14 +7,17 @@ it('re-derives SQLite assignments from malformed disposable cache data', async ( const version = createHash('sha256') .update(readFileSync(new URL('./vitest-sqlite-runtime-scan.mjs', import.meta.url))) .digest('hex') - for (const files of [null, { 'fixture.test.ts': [1, 2, 'false'] }]) { + for (const files of [null, { 'src/fixture.test.ts': [1, 2, 'false'] }]) { for (const [source, expected] of [ ['export {}', []], - ["import './src/main/sqlite/sync-database'", ['fixture.test.ts']] + ["import './main/sqlite/sync-database'", ['src/fixture.test.ts']] ] as const) { vi.doMock('node:fs', async (original) => ({ ...(await original()), - globSync: () => ['fixture.test.ts'], + readdirSync: (path: string) => + String(path).endsWith('src') + ? [{ name: 'fixture.test.ts', isDirectory: () => false }] + : [], statSync: () => ({ mtimeMs: 1, size: 2 }), readFileSync: (path: Parameters[0]) => String(path).endsWith('.json') diff --git a/config/scripts/vitest-sqlite-runtime-scan.mjs b/config/scripts/vitest-sqlite-runtime-scan.mjs index 05ca604474c..5be2e603f60 100644 --- a/config/scripts/vitest-sqlite-runtime-scan.mjs +++ b/config/scripts/vitest-sqlite-runtime-scan.mjs @@ -1,8 +1,7 @@ import { createHash } from 'node:crypto' -import { globSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs' +import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { basename, dirname, join, resolve } from 'node:path' -import { defaultExclude } from 'vitest/config' +import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' import { UNIT_INCLUDE } from './ci-unit-files.mjs' const require = createRequire(import.meta.url) @@ -88,17 +87,70 @@ function readCache(version) { } } -/** Unit tests that import a SQLite module or harness directly, as repo-relative paths. */ -export function findSqliteRuntimeTests() { +function walkFiles(directory, files) { + let entries + try { + entries = readdirSync(resolve(root, directory), { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const path = `${directory}/${entry.name}` + if (!entry.isDirectory()) { + files.push(path) + } else if (entry.name !== 'node_modules' && entry.name !== '.git') { + // Vitest's defaultExclude, pruned instead of matched per file. + walkFiles(path, files) + } + } +} + +/** UNIT_INCLUDE files as repo-relative paths; a plain walk is several times faster than fs.globSync. */ +export function listUnitTestFiles() { + const files = [] + for (const base of new Set(UNIT_INCLUDE.map((pattern) => pattern.split('/**')[0]))) { + walkFiles(base, files) + } + return files.filter((file) => UNIT_INCLUDE.some((pattern) => posix.matchesGlob(file, pattern))) +} + +/** + * Vitest's CLI filter match (a case-insensitive path substring). Also tries each filter without + * a `:line` suffix, so the selection is never narrower than the one Vitest runs. + */ +export function matchesRunFilter(file, filters) { + const testFile = file.toLowerCase() + const absolute = resolve(root, file).replaceAll('\\', '/') + return filters.some((raw) => + [raw, raw.replace(/(?::\d+)+$/, '')].some((filter) => { + const slashed = filter.replaceAll('\\', '/') + const relativePath = relative(process.cwd(), filter).replaceAll('\\', '/') + return ( + (isAbsolute(filter) && absolute.startsWith(slashed)) || + testFile.includes(slashed.toLowerCase()) || + testFile.includes(relativePath.toLowerCase()) + ) + }) + ) +} + +/** + * Unit tests that import a SQLite module or harness directly, as repo-relative paths. With CLI + * `filters`, only the tests those filters can select are classified. + */ +export function findSqliteRuntimeTests({ filters } = {}) { // Why: reading every test costs about a second; unchanged files reuse their last verdict. const version = createHash('sha256') .update(readFileSync(import.meta.filename)) .digest('hex') const cached = readCache(version) + const scoped = Boolean(filters?.length) + const candidates = listUnitTestFiles() const files = {} let changed = false - for (const file of globSync(UNIT_INCLUDE, { cwd: root, exclude: defaultExclude })) { - const path = file.replaceAll('\\', '/') + for (const path of scoped + ? candidates.filter((file) => matchesRunFilter(file, filters)) + : candidates) { const { mtimeMs, size } = statSync(resolve(root, path)) const entry = cached[path] if ( @@ -117,11 +169,13 @@ export function findSqliteRuntimeTests() { ] changed = true } - if (changed || Object.keys(cached).length !== Object.keys(files).length) { + if (changed || (!scoped && Object.keys(cached).length !== Object.keys(files).length)) { try { mkdirSync(dirname(CACHE_FILE), { recursive: true }) const temporary = `${CACHE_FILE}.${process.pid}` - writeFileSync(temporary, JSON.stringify({ version, files })) + // Only a full scan knows which files were deleted; a scoped one keeps verdicts it skipped. + const merged = scoped ? { ...cached, ...files } : files + writeFileSync(temporary, JSON.stringify({ version, files: merged })) // Why: parallel runs each rename a whole file, so a reader never sees a partial write. renameSync(temporary, CACHE_FILE) } catch { diff --git a/config/scripts/vitest-zod-bundle.mjs b/config/scripts/vitest-zod-bundle.mjs index e51cdb5de98..5ade1d61bc6 100644 --- a/config/scripts/vitest-zod-bundle.mjs +++ b/config/scripts/vitest-zod-bundle.mjs @@ -1,28 +1,89 @@ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { buildSync } from 'esbuild' +import { buildSync, version as esbuildVersion } from 'esbuild' +const CACHE_DIRECTORY = 'vitest-zod-cache' +// No run lasts this long, so an older bundle directory is not in use. +const STALE_AGE_MS = 6 * 60 * 60 * 1000 + +function removeIfStale(path, now) { + try { + if (now - statSync(path).mtimeMs > STALE_AGE_MS) { + rmSync(path, { recursive: true, force: true, maxRetries: 3 }) + } + } catch { + // Another launcher may be removing it too. + } +} + +/** Per-run directories that older launchers leaked when killed, and superseded cache keys. */ +function sweepStaleBundles(temporary, cache, key) { + const now = Date.now() + for (const name of readdirSync(temporary)) { + if (name.startsWith('vitest-zod-') && name !== CACHE_DIRECTORY) { + removeIfStale(join(temporary, name), now) + } + } + for (const name of readdirSync(cache)) { + if (name !== key && !name.startsWith(`.${key}-`)) { + removeIfStale(join(cache, name), now) + } + } +} + +/** + * Builds Zod into one ESM module that every Vitest worker aliases. The output is keyed by the + * installed Zod, esbuild, and this builder, so repeat runs reuse it and a killed run leaks nothing. + */ export function createVitestZodBundle(root) { + const entry = fileURLToPath(import.meta.resolve('zod')) + const manifest = createRequire(entry).resolve('zod/package.json') + const key = createHash('sha256') + .update(JSON.stringify([entry, readFileSync(manifest, 'utf8'), esbuildVersion])) + .update(readFileSync(import.meta.filename)) + .digest('hex') + .slice(0, 16) const temporary = join(root, '.tmp') - mkdirSync(temporary, { recursive: true }) - const directory = mkdtempSync(join(temporary, 'vitest-zod-')) - const path = join(directory, 'zod.mjs') - const cleanup = () => rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + const cache = join(temporary, CACHE_DIRECTORY) + const path = join(cache, key, 'zod.mjs') + mkdirSync(cache, { recursive: true }) + sweepStaleBundles(temporary, cache, key) + if (existsSync(path)) { + return { path } + } + // Built beside the final directory, then renamed, so readers never see a partial bundle. + const staging = join(cache, `.${key}-${process.pid}-${Date.now()}`) try { buildSync({ absWorkingDir: root, - entryPoints: [fileURLToPath(import.meta.resolve('zod'))], - outfile: path, + entryPoints: [entry], + outfile: join(staging, 'zod.mjs'), bundle: true, platform: 'node', format: 'esm', target: 'node24', sourcemap: true }) - return { path, cleanup } - } catch (error) { - cleanup() - throw error + try { + renameSync(staging, dirname(path)) + } catch (error) { + if (!existsSync(path)) { + throw error + } + } + return { path } + } finally { + rmSync(staging, { recursive: true, force: true, maxRetries: 3 }) } } diff --git a/config/scripts/vitest-zod-bundle.test.mjs b/config/scripts/vitest-zod-bundle.test.mjs new file mode 100644 index 00000000000..19e1b8e1abc --- /dev/null +++ b/config/scripts/vitest-zod-bundle.test.mjs @@ -0,0 +1,26 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { createVitestZodBundle } from './vitest-zod-bundle.mjs' + +let root +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('reuses one bundle across runs and removes directories leaked by killed runs', () => { + root = mkdtempSync(join(tmpdir(), 'orca-zod-bundle-')) + const leaked = join(root, '.tmp', 'vitest-zod-leaked') + const active = join(root, '.tmp', 'vitest-zod-active') + mkdirSync(leaked, { recursive: true }) + mkdirSync(active) + const hoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000) + utimesSync(leaked, hoursAgo, hoursAgo) + + const first = createVitestZodBundle(root) + const second = createVitestZodBundle(root) + + expect(second.path).toBe(first.path) + expect(existsSync(first.path)).toBe(true) + expect(existsSync(leaked)).toBe(false) + expect(existsSync(active)).toBe(true) +}) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index d052c8d1f48..34065de49bf 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -7,9 +7,12 @@ import TimingSequencer from './scripts/ci-unit-sequencer.mjs' import RuntimeSequencer from './scripts/vitest-runtime-sequencer.mjs' import { getNodeRuntimeInclude } from './scripts/vitest-node-runtime-files.mjs' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' +import { readRunFilters } from './scripts/vitest-run-filters.mjs' import { createWorkspaceSourceResolver } from './scripts/workspace-source-exports.mjs' -const nodeRuntimeInclude = process.versions.bun ? getNodeRuntimeInclude() : [] +const nodeRuntimeInclude = process.versions.bun + ? getNodeRuntimeInclude({ filters: readRunFilters() }) + : [] const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1' const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts' const paletteMeasurementFile = diff --git a/package.json b/package.json index 28d90cc587c..2355aad9554 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "prepare": "husky", - "test": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", - "test:changed": "pnpm test --changed=HEAD", + "test": "node config/scripts/build-workspace-packages.mjs && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts", + "test:changed": "node config/scripts/build-workspace-packages.mjs && node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/run-vitest.mjs run --config config/vitest.config.ts --changed=HEAD", "test:node": "pnpm run build:packages && node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:node-server": "node config/scripts/run-node-server-tests.mjs", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", @@ -195,7 +195,7 @@ "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs", "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings", "sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs", - "build:packages": "pnpm --filter @orca/process-host build", + "build:packages": "node config/scripts/build-workspace-packages.mjs", "check:process-host-imports": "node config/scripts/check-process-host-imports.mjs", "check:main-sync-spawn-ratchet": "node config/scripts/check-main-sync-spawn-ratchet.mjs", "check:compat-boundary-imports": "node config/scripts/check-compat-boundary-imports.mjs" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 387916d7381..d3cdb241cc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,7 @@ patchedDependencies: node-pty@1.1.0: d3e7df08a1b7c11400cf9db039ece2b7c8a8d0ce4e2f79d81d52c10a67dc0440 oxlint-plugin-anti-slop@0.1.2: c10c871702365a0c2c59e9f1c5fb92df97cae5755d534c0a3d83c6368f6feafe sprintf-js@1.1.3: 05316a8b01f155fe19a1e0cb4042c81d43d798a76755c407684e1199a18b1e08 + vitest@5.0.5: e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2 ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -322,7 +323,7 @@ importers: version: 3.14.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: 6.9.1(vitest@5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) @@ -619,7 +620,7 @@ importers: version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 5.0.5 - version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -680,7 +681,7 @@ importers: version: 7.0.2 vitest: specifier: 5.0.5 - version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) packages: @@ -10366,7 +10367,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + '@testing-library/jest-dom@6.9.1(vitest@5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': dependencies: '@adobe/css-tools': 4.5.0 '@types/aria-query': 5.0.4 @@ -10376,7 +10377,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)': dependencies: @@ -15099,7 +15100,7 @@ snapshots: transitivePeerDependencies: - '@types/picomatch' - vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) @@ -15124,7 +15125,7 @@ snapshots: - '@types/picomatch' - msw - vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.5(patch_hash=e53d360918437da97f766d322f487aabfbe10d1450ed98543491573b4d500cd2)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92ab6b420f4..250776c84c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -139,3 +139,4 @@ patchedDependencies: monaco-editor@0.55.1: config/patches/monaco-editor@0.55.1.patch sprintf-js@1.1.3: config/patches/sprintf-js@1.1.3.patch '@tiptap/extension-list@3.31.3': config/patches/@tiptap__extension-list@3.31.3.patch + vitest@5.0.5: config/patches/vitest@5.0.5.patch