From aadd6fb27bbf4bd940bb68efbbee5886e456b447 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:32:02 -0700 Subject: [PATCH] perf(test): cache local Vitest dependencies and add changed targeting (#33305) --- .github/CONTRIBUTING.md | 36 ++++++ AGENTS.md | 1 + config/scripts/vitest-affected-tests.test.mjs | 112 ++++++++++++++++++ config/vitest.config.ts | 11 +- package.json | 1 + 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 config/scripts/vitest-affected-tests.test.mjs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3bb20b827f2..0f3ceb372ba 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -26,6 +26,42 @@ pnpm install pnpm dev ``` +### Targeting Local Tests + +Use a test file or directory when you know the relevant suites. For dependency-based +feedback, Vitest can select tests affected by your edits: + +```bash +pnpm test:changed src/main/runtime --maxWorkers=2 +pnpm test --changed=origin/main --maxWorkers=2 +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`. + +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. + +This is local feedback, not a complete regression check. Vitest records loaded +imports, including executed dynamic imports, but cannot infer arbitrary filesystem +reads, subprocess inputs, or externalized module dependencies. Explicitly run tests +for changed fixtures, generated files, CLI entry points, and other indirect inputs. +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. + +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 +inputs automatically and exits after one run. + To reduce main-process rebuild CPU and memory when you do not need its source maps, set `ORCA_DEV_MAIN_SOURCEMAPS=0` before `pnpm dev`. In Bash/zsh: diff --git a/AGENTS.md b/AGENTS.md index 2901079247c..8bf95d9df5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`) - Local typechecks use incremental tsrs on Apple Silicon macOS and x64/arm64 Linux; other platforms use Bun. Keep `.cache/typecheck/` between checks; delete it to force a fresh local check. `pnpm typecheck:ci` explicitly uses Bun; `pnpm tc --compiler=native` uses TypeScript. - **Test**: `pnpm test [path/to/file.test.ts]` +- **Affected tests**: `pnpm test:changed [path] --maxWorkers=2` for uncommitted edits, or `pnpm test --changed=origin/main` for branch changes. Local runs cache loaded imports; cold caches run all tests in scope. Filesystem/subprocess inputs need explicit tests; see [test targeting](.github/CONTRIBUTING.md#targeting-local-tests). - **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format` - **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces diff --git a/config/scripts/vitest-affected-tests.test.mjs b/config/scripts/vitest-affected-tests.test.mjs new file mode 100644 index 00000000000..4e3c6f401e3 --- /dev/null +++ b/config/scripts/vitest-affected-tests.test.mjs @@ -0,0 +1,112 @@ +import { runProcessSync } from '@orca/process-host' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { expect, it } from 'vitest' + +it('uses recorded imports in both runtimes and retains cold, new, and global-input tests', () => { + const root = process.cwd() + mkdirSync(join(root, '.tmp'), { recursive: true }) + const directory = mkdtempSync(join(root, '.tmp', 'vitest-affected-')) + const config = join(directory, 'vitest.config.mjs') + const report = join(directory, 'report.json') + const transformed = join(directory, 'transformed.jsonl') + const input = join(directory, 'value.ts') + const runtimes = ['bun', 'node-runtime'] + const localPath = (file) => relative(root, file).replaceAll('\\', '/') + function writeTest(runtime, name, imported = false) { + const file = join(directory, runtime, `${name}.test.ts`) + writeFileSync( + file, + ` + import { expect, it } from 'vitest' + ${imported ? "import { value } from '../value'" : 'const value = 1'} + it('runs under ${runtime}', () => { + expect(value).toBe(1) + expect(Boolean(process.versions.bun)).toBe(${runtime === 'bun'}) + }) + ` + ) + return file + } + try { + writeFileSync(input, 'export const value = 1') + const affected = [] + const unrelated = [] + for (const runtime of runtimes) { + mkdirSync(join(directory, runtime)) + affected.push(writeTest(runtime, 'affected', true)) + unrelated.push(writeTest(runtime, 'unrelated')) + } + writeFileSync( + config, + ` + import base from ${JSON.stringify(pathToFileURL(resolve('config/vitest.config.ts')).href)} + import { appendFileSync } from 'node:fs' + export default { + ...base, + cacheDir: ${JSON.stringify(join(directory, 'cache'))}, + plugins: [...base.plugins, { + name: 'observe-affected-test-transforms', + transform(code, id) { + if (id.replaceAll('\\\\', '/').startsWith(${JSON.stringify(directory.replaceAll('\\', '/'))})) { + appendFileSync(${JSON.stringify(transformed)}, JSON.stringify(id) + '\\n') + } + } + }], + test: { + ...base.test, + maxWorkers: 1, + projects: base.test.projects + .filter(project => ${JSON.stringify(runtimes)}.includes(project.test.name)) + .map(project => ({ + ...project, + test: { + ...project.test, + include: [${JSON.stringify(localPath(directory))} + '/' + project.test.name + '/*.test.ts'], + exclude: [] + } + })) + } + } + ` + ) + function run(changed) { + writeFileSync(transformed, '') + const result = runProcessSync({ + program: process.execPath, + args: [ + resolve('config/scripts/run-vitest.mjs'), + 'related', + '--run', + localPath(changed), + '--config', + config, + '--reporter=json', + '--outputFile', + report + ], + cwd: root, + env: { ...process.env, CI: '', ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 60_000 + }) + expect(result.timedOut, result.stderr).toBe(false) + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + const output = JSON.parse(readFileSync(report, 'utf8')) + expect(output.success).toBe(true) + return output.testResults.map(({ name }) => name.replaceAll('\\', '/')).sort() + } + const normalize = (files) => files.map((file) => file.replaceAll('\\', '/')).sort() + expect(run(input)).toEqual(normalize([...affected, ...unrelated])) + expect(run(input)).toEqual(normalize(affected)) + const transforms = readFileSync(transformed, 'utf8') + for (const file of unrelated) { + expect(transforms).not.toContain(JSON.stringify(file.replaceAll('\\', '/'))) + } + const fresh = writeTest(runtimes[0], 'new') + expect(run(input)).toEqual(normalize([...affected, fresh])) + expect(run(join(root, 'pnpm-lock.yaml'))).toEqual(normalize([...affected, ...unrelated, fresh])) + } finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + } +}, 180_000) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index b080ce434b1..d052c8d1f48 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { resolve } from 'node:path' -import { defaultExclude, defineConfig } from 'vitest/config' +import { configDefaults, defaultExclude, defineConfig } from 'vitest/config' import { UNIT_INCLUDE, UNIT_EXCLUDE, UNIT_SETUP_FILES } from './scripts/ci-unit-files.mjs' import TimingSequencer from './scripts/ci-unit-sequencer.mjs' import RuntimeSequencer from './scripts/vitest-runtime-sequencer.mjs' @@ -131,6 +131,15 @@ export default defineConfig({ ...transforms, test: { ...testOptions, + experimental: { recordDependencies: !process.env.CI }, + forceRerunTriggers: [ + ...configDefaults.forceRerunTriggers, + '**/pnpm-lock.yaml', + '**/pnpm-workspace.yaml', + '**/config/**', + '**/patches/**', + '**/src/packages/**' + ], sequence: { sequencer: balancedShards ? TimingSequencer : RuntimeSequencer }, ...(balancedShards ? { diff --git a/package.json b/package.json index 020559b3a99..1c5c36dc19e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "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": "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",