From ada2a5e1886dbb1ab6d2ecce38d887fc4f9e1c26 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 28 Sep 2026 20:17:28 +0900 Subject: [PATCH 01/37] chore(deps): ignore rrweb-snapshot in renovate (#11290) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Codex --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 87294e000..39c1c7bd4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -29,6 +29,7 @@ '@types/chai', '@sinonjs/fake-timers', 'cac', + 'rrweb-snapshot', // Transitive dependency that we patch 'acorn', // Keep using codemirror 5 -- 2.51.2 From 5dbebe9e3b8758fe97d233eb9718a3664cd0b9c3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 28 Sep 2026 20:20:02 +0900 Subject: [PATCH 02/37] fix: isolate `result.status` between `repeats` runs (#11218) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Codex (GPT-6) --- docs/api/advanced/runner.md | 6 +- packages/vitest/src/runtime/runner/run.ts | 11 ++ packages/vitest/src/runtime/runner/types.ts | 6 +- test/e2e/test/repeats.test.ts | 112 +++++++++++++++++++- test/unit/test/on-finished.test.ts | 1 - test/unit/test/repeats.test.ts | 6 +- 6 files changed, 127 insertions(+), 15 deletions(-) diff --git a/docs/api/advanced/runner.md b/docs/api/advanced/runner.md index bec83f9bb..f4f6561ad 100644 --- a/docs/api/advanced/runner.md +++ b/docs/api/advanced/runner.md @@ -261,13 +261,11 @@ export interface TaskResult { */ hooks?: Partial> /** - * The amount of times the task was retried. The task is retried only if it - * failed and `retry` option is set. + * The number of times the task has been retried across all repeats. */ retryCount?: number /** - * The amount of times the task was repeated. The task is repeated only if - * `repeats` option is set. This number also contains `retryCount`. + * The zero-based index of the current repeat. */ repeatCount?: number } diff --git a/packages/vitest/src/runtime/runner/run.ts b/packages/vitest/src/runtime/runner/run.ts index 26a2c72d5..3fcf0910f 100644 --- a/packages/vitest/src/runtime/runner/run.ts +++ b/packages/vitest/src/runtime/runner/run.ts @@ -596,7 +596,10 @@ async function runTest(test: Test, runner: VitestRunner): Promise { const $ = runner.trace! const repeats = test.repeats ?? 0 + let hasFailedRepeat = false for (let repeatCount = 0; repeatCount <= repeats; repeatCount++) { + // Force widening to TaskState because TypeScript cannot track mutations made by hooks and the test. + test.result.state = 'run' as TaskState const retry = getRetryCount(test.retry) for (let retryCount = 0; retryCount <= retry; retryCount++) { let beforeEachCleanups: unknown[] = [] @@ -726,6 +729,14 @@ async function runTest(test: Test, runner: VitestRunner): Promise { // update retry info updateTask('test-retried', test, runner) } + + if (test.result.state === 'fail') { + hasFailedRepeat = true + } + } + + if (hasFailedRepeat) { + test.result.state = 'fail' } // if test is marked to be failed, flip the result unless `TestSyntaxError` is present diff --git a/packages/vitest/src/runtime/runner/types.ts b/packages/vitest/src/runtime/runner/types.ts index 8ca16e737..d57028ead 100644 --- a/packages/vitest/src/runtime/runner/types.ts +++ b/packages/vitest/src/runtime/runner/types.ts @@ -192,13 +192,11 @@ export interface TaskResult { */ hooks?: Partial> /** - * The amount of times the task was retried. The task is retried only if it - * failed and `retry` option is set. + * The number of times the task has been retried across all repeats. */ retryCount?: number /** - * The amount of times the task was repeated. The task is repeated only if - * `repeats` option is set. This number also contains `retryCount`. + * The zero-based index of the current repeat. */ repeatCount?: number /** @internal */ diff --git a/test/e2e/test/repeats.test.ts b/test/e2e/test/repeats.test.ts index a1edb69b8..297916168 100644 --- a/test/e2e/test/repeats.test.ts +++ b/test/e2e/test/repeats.test.ts @@ -1,7 +1,7 @@ import type { TestModule } from 'vitest/node' import { resolve } from 'pathe' import { expect, test } from 'vitest' -import { runVitest } from '../../test-utils' +import { runInlineTests, runVitest } from '../../test-utils' const root = resolve(__dirname, '..', 'fixtures', 'repeats') @@ -24,3 +24,113 @@ test('repeats config option is exposed to tests and repeats execution', async () expect(overridden.options.repeats).toBe(1) expect(overridden.diagnostic()!.repeatCount).toBe(1) }) + +test('retry count and errors are retained across repeats', async () => { + const { errorTree, results } = await runInlineTests({ + 'repeats.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const runs = [] + + it('retries each repeat once', { repeats: 2, retry: 1 }, ({ task }) => { + const repeatCount = task.result.repeatCount + const retryCount = task.result.retryCount + runs.push([repeatCount, retryCount]) + if (repeatCount === retryCount) { + throw new Error('repeat ' + repeatCount + ', retry ' + retryCount + ' failed') + } + }) + + afterAll(() => { + expect(runs).toEqual([ + [0, 0], + [0, 1], + [1, 1], + [1, 2], + [2, 2], + [2, 3], + ]) + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "retries each repeat once": "passed", + }, + } + `) + const [test] = results[0].children.allTests() + expect(test.result().errors?.map((error) => error.message)).toMatchInlineSnapshot(` + [ + "repeat 0, retry 0 failed", + "repeat 1, retry 1 failed", + "repeat 2, retry 2 failed", + ] + `) +}) + +test('failed repeats are retained after a successful repeat', async () => { + const { errorTree, results } = await runInlineTests({ + 'repeats.test.js': ` + import { it } from 'vitest' + + it('fails twice then passes', { repeats: 2, retry: 1 }, ({ task }) => { + if (task.result.repeatCount < 2) { + throw new Error('repeat ' + task.result.repeatCount + ', retry ' + task.result.retryCount + ' failed') + } + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "fails twice then passes": [ + "repeat 0, retry 0 failed", + "repeat 0, retry 1 failed", + "repeat 1, retry 1 failed", + "repeat 1, retry 2 failed", + ], + }, + } + `) + const [test] = results[0].children.allTests() + expect(test.diagnostic()!.retryCount).toBe(2) + expect(test.diagnostic()!.repeatCount).toBe(2) +}) + +test('onTestFailed runs only for failed repeats', async () => { + const { errorTree } = await runInlineTests({ + 'repeats.test.js': ` + import { expect, it } from 'vitest' + + const failedRepeats = [] + + it('alternates passing and failing repeats', { repeats: 4 }, ({ task, onTestFailed }) => { + const repeatCount = task.result.repeatCount + onTestFailed(() => failedRepeats.push(repeatCount)) + if (repeatCount % 2 === 1) { + throw new Error('repeat ' + repeatCount + ' failed') + } + }) + + it('records failed repeats', () => { + expect(failedRepeats).toEqual([1, 3]) + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "alternates passing and failing repeats": [ + "repeat 1 failed", + "repeat 3 failed", + ], + "records failed repeats": "passed", + }, + } + `) +}) diff --git a/test/unit/test/on-finished.test.ts b/test/unit/test/on-finished.test.ts index 57ebc1690..99c1e88cc 100644 --- a/test/unit/test/on-finished.test.ts +++ b/test/unit/test/on-finished.test.ts @@ -120,7 +120,6 @@ describe('repeats fail', () => { "(0, 1) fail", "(0, 2) run", "(0, 2) finish", - "(0, 2) fail", ] `) }) diff --git a/test/unit/test/repeats.test.ts b/test/unit/test/repeats.test.ts index 0b369008d..14f44e6af 100644 --- a/test/unit/test/repeats.test.ts +++ b/test/unit/test/repeats.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test, TestRunner } from 'vitest' +import { afterAll, describe, expect, test } from 'vitest' const testNumbers: number[] = [] @@ -50,10 +50,6 @@ describe('testing repeats with retry', () => { expect(retryNumbers).toStrictEqual(result) }) }) - - test('should not reset retry count', { repeats: 2, retry: 1 }, () => { - expect(TestRunner.getCurrentTest()!.result?.retryCount).toBe(3) - }) }) const nestedDescribeNumbers: number[] = [] -- 2.51.2 From 15cc006aa9f8683dc5f7e895d011fa7dc751ce35 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 28 Sep 2026 14:06:15 +0200 Subject: [PATCH 03/37] fix: don't print an interceptor warning in browser mode (#11377) --- packages/browser/src/node/rpc.ts | 12 +++++++----- packages/mocker/src/node/interceptorPlugin.ts | 17 ++++++++++------- packages/mocker/src/utils.ts | 8 ++++---- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index cc69e7814..a252a9819 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -32,6 +32,7 @@ import { isValidApiRequest, } from 'vitest/node' import { WebSocketServer } from 'ws' +import { slash } from './utils' const debug = createDebugger('vitest:browser:api') @@ -293,7 +294,7 @@ export function setupBrowserRpc( for (const attachment of attachments) { const path = attachment.path if (path && !path.startsWith('http://') && !path.startsWith('https://')) { - checkFileAccess(resolve(project.config.root, path)) + checkFileAccess(resolve(project.config.root, slash(path))) } } } @@ -304,7 +305,7 @@ export function setupBrowserRpc( return vitest._testRun.recordBenchmark(testId, benchmark) }, async readBenchmarkResult(relativePath) { - checkFileAccess(project.benchmark.resolve(relativePath)) + checkFileAccess(project.benchmark.resolve(slash(relativePath))) return project.benchmark.readResult(relativePath) }, async writeBenchmarkResult(relativePath, data) { @@ -314,7 +315,7 @@ export function setupBrowserRpc( ) return } - checkFileAccess(project.benchmark.resolve(relativePath)) + checkFileAccess(project.benchmark.resolve(slash(relativePath))) return project.benchmark.writeResult(relativePath, data) }, async onTaskUpdate(method, packs, events) { @@ -479,8 +480,9 @@ export function setupBrowserRpc( } else { if (module.type === 'redirect') { const redirectUrl = new URL(module.redirect) - module.redirect = join(vite.config.root, redirectUrl.pathname) - checkFileAccess(module.redirect) + const redirect = join(vite.config.root, slash(redirectUrl.pathname)) + checkFileAccess(redirect) + module.redirect = redirect } defaultMockerRegistry.register(module) } diff --git a/packages/mocker/src/node/interceptorPlugin.ts b/packages/mocker/src/node/interceptorPlugin.ts index 3319823db..65150373f 100644 --- a/packages/mocker/src/node/interceptorPlugin.ts +++ b/packages/mocker/src/node/interceptorPlugin.ts @@ -2,6 +2,7 @@ import type { Plugin } from 'vite' import type { MockedModuleSerialized } from '../registry' import { readFile } from 'node:fs/promises' import { join } from 'node:path/posix' +import { slash } from '@vitest/utils/helpers' import { isFileLoadingAllowed } from 'vite' import { ManualMockedModule, MockerRegistry } from '../registry' import { cleanUrl, createManualModuleSource } from '../utils' @@ -24,7 +25,7 @@ export interface InterceptorPluginOptions { export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugin { const registry = options.registry || new MockerRegistry() - return { + const plugin: Plugin = { name: 'vitest:mocks:interceptor', enforce: 'pre', load: { @@ -63,10 +64,10 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi } }, }, - configureServer(server) { - if (options.registerWebSocketEvents === false) { - return - } + } + + if (options.registerWebSocketEvents !== false) { + plugin.configureServer = (server) => { server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { if (event.type === 'manual') { const module = ManualMockedModule.fromJSON(event, async () => { @@ -77,7 +78,7 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi } else { if (event.type === 'redirect') { const redirectUrl = new URL(event.redirect) - const redirect = join(server.config.root, redirectUrl.pathname) + const redirect = join(server.config.root, slash(redirectUrl.pathname)) // the redirect is served through the `load` hook below, so it must // stay inside the file-serving allowlist and never escape the root if (!isFileLoadingAllowed(server.config, redirect)) { @@ -117,6 +118,8 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi ) }) } - }, + } } + + return plugin } diff --git a/packages/mocker/src/utils.ts b/packages/mocker/src/utils.ts index 26143ba6f..43bdaa9c1 100644 --- a/packages/mocker/src/utils.ts +++ b/packages/mocker/src/utils.ts @@ -9,12 +9,12 @@ export function createManualModuleSource( globalAccessor = '"__vitest_mocker__"', ): string { const source = ` -const __factoryModule__ = await globalThis[${globalAccessor}].getFactoryModule("${moduleUrl}"); +const __factoryModule__ = await globalThis[${globalAccessor}].getFactoryModule(${JSON.stringify(moduleUrl)}); ` const keys = exports .map((name, index) => { - return `let __${index} = __factoryModule__["${name}"] -export { __${index} as "${name}" }` + return `let __${index} = __factoryModule__[${JSON.stringify(name)}] +export { __${index} as ${JSON.stringify(name)} }` }) .join('\n') let code = `${source}\n${keys}` @@ -24,7 +24,7 @@ if (__factoryModule__.__factoryPromise != null) { __factoryModule__.__factoryPromise.then((resolvedModule) => { ${exports .map((name, index) => { - return `__${index} = resolvedModule["${name}"];` + return `__${index} = resolvedModule[${JSON.stringify(name)}];` }) .join('\n')} }) -- 2.51.2 From 07cd7320b5e882a63f80076aac8b6fc9c1b1da24 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 28 Sep 2026 21:15:38 +0900 Subject: [PATCH 04/37] docs: document cwd behavior on projects (#11376) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Codex (GPT-6) --- docs/guide/common-errors.md | 25 +++++++++++++++++++++++++ docs/guide/projects.md | 2 ++ 2 files changed, 27 insertions(+) diff --git a/docs/guide/common-errors.md b/docs/guide/common-errors.md index d89c991c2..783aaa7f8 100644 --- a/docs/guide/common-errors.md +++ b/docs/guide/common-errors.md @@ -49,6 +49,31 @@ This error can happen when NodeJS's `fetch` is used with [`pool: 'threads'`](/co The default [`pool: 'forks'`](/config/pool#forks) does not have this issue. If you've explicitly set `pool: 'threads'`, switching back to `'forks'` or using [`'vmForks'`](/config/pool#vmforks) will resolve it. +## Project Working Directory Does Not Change + +In a [multi-project run](/guide/projects), `process.cwd()` in project config files and tests returns the directory where Vitest was started by default. A project's [`root`](/config/root) controls where Vitest looks for its files, but it does not change the process working directory. Vite plugins can read the project root from the resolved Vite config's `root` property. + +If your tests need `process.cwd()` to point to the project directory, use the [`forks` pool](/config/pool#forks) and a project-specific [`setupFiles`](/config/setupfiles) file: + +```ts [packages/lib1/vitest.config.ts] +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + pool: 'forks', + setupFiles: ['./setup.chdir.ts'], + }, +}) +``` + +```ts [packages/lib1/setup.chdir.ts] +import { fileURLToPath } from 'node:url' + +process.chdir(fileURLToPath(new URL('.', import.meta.url))) +``` + +This changes the working directory in the test worker, after config loading. The [`threads` pool](/config/pool#threads) cannot use `process.chdir()`. + ## Custom package conditions are not resolved If you are using custom conditions in your `package.json` [exports](https://nodejs.org/api/packages.html#package-entry-points) or [subpath imports](https://nodejs.org/api/packages.html#subpath-imports), you may find that Vitest does not respect these conditions by default. diff --git a/docs/guide/projects.md b/docs/guide/projects.md index 79a6bc4f0..811aeb0fe 100644 --- a/docs/guide/projects.md +++ b/docs/guide/projects.md @@ -171,6 +171,8 @@ export default defineProject({ }) ``` +By default, `process.cwd()` in every project's tests returns the directory where Vitest was started, even if the project has a different root. See [Project Working Directory Does Not Change](/guide/common-errors#project-working-directory-does-not-change) for details and a workaround. + ## Running Tests To run tests, define a script in your root `package.json`: -- 2.51.2 From aafc0996f1641f824d5053bddf536f2598fe82c0 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Mon, 28 Sep 2026 15:29:10 +0200 Subject: [PATCH 05/37] fix(browser): keep config of browser-consumed environments (#11378) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/node/plugins/runnerTransform.ts | 13 ++++++--- test/e2e/test/config/browser-configs.test.ts | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/vitest/src/node/plugins/runnerTransform.ts b/packages/vitest/src/node/plugins/runnerTransform.ts index 77e25ed8f..7f257a5bb 100644 --- a/packages/vitest/src/node/plugins/runnerTransform.ts +++ b/packages/vitest/src/node/plugins/runnerTransform.ts @@ -35,6 +35,10 @@ export function ModuleRunnerTransform(): VitePlugin { config.environments[name] ??= {} const environment = config.environments[name] + // other environments consumed by the browser are not run by Vitest + if (browserEnabled && name !== 'client' && environment.consumer === 'client') { + continue + } environment.dev ??= {} // vm tests run using the native import mechanism if (name === '__vitest_vm__') { @@ -58,10 +62,11 @@ export function ModuleRunnerTransform(): VitePlugin { if (name === '__vitest_vm__' || name === '__vitest__') { return } - // In browser mode the `client` environment is browser-managed: don't - // apply node-runner externalization / `optimizeDeps` to it (that would - // discard the browser `optimizeDeps.include`, e.g. `vitest > expect-type`). - if (name === 'client' && testConfig.browser?.enabled) { + // In browser mode the `client` environment (and any other environment + // consumed by the browser) is browser-managed: don't apply node-runner + // externalization / `optimizeDeps` to it (that would discard the browser + // `optimizeDeps.include`, e.g. `vitest > expect-type`). + if (testConfig.browser?.enabled && (name === 'client' || config.consumer === 'client')) { return } diff --git a/test/e2e/test/config/browser-configs.test.ts b/test/e2e/test/config/browser-configs.test.ts index 95c680b36..bd0e48dac 100644 --- a/test/e2e/test/config/browser-configs.test.ts +++ b/test/e2e/test/config/browser-configs.test.ts @@ -133,6 +133,35 @@ test('pre-bundles vite module runner through vitest in browser mode', async () = expect(v.vite.config.optimizeDeps.exclude).not.toContain('vite/module-runner') }) +test('keeps the config of other browser-consumed environments in browser mode', async () => { + const v = await vitest({ + browser: { + enabled: true, + provider: preview(), + instances: [{ browser: 'chromium' }], + }, + $viteConfig: { + environments: { + page_runner: { + consumer: 'client', + dev: { moduleRunnerTransform: true, preTransformRequests: true }, + optimizeDeps: { include: ['react'] }, + }, + }, + }, + }) + + const { page_runner, ssr } = v.vite.config.environments + expect(page_runner.dev.preTransformRequests).toBe(true) + expect(page_runner.keepProcessEnv).toBe(false) + expect(page_runner.resolve.noExternal).not.toBe(true) + expect(page_runner.optimizeDeps.noDiscovery).toBe(false) + expect(page_runner.optimizeDeps.include).toEqual(['react']) + // environments that Vitest runs on the server keep the node-runner config + expect(ssr.optimizeDeps.noDiscovery).toBe(true) + expect(ssr.resolve.noExternal).toBe(true) +}) + test('disables pre-transform requests in node mode', async () => { expect(await observePreTransformRequests()).toEqual({ client: false, -- 2.51.2 From 6c49b71978c23ef1dc04c2ef5fa73af8f9bc5ec0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 28 Sep 2026 15:36:49 +0200 Subject: [PATCH 06/37] fix(jsdom): support Blob on jsdom 30.1 (#11379) --- examples/lit/package.json | 2 +- examples/projects/package.json | 2 +- packages/vitest/package.json | 4 +- packages/vitest/src/integrations/env/jsdom.ts | 37 ++- pnpm-lock.yaml | 241 ++++++++++-------- test/unit/test/environments/jsdom.spec.ts | 11 + test/workspaces/package.json | 2 +- 7 files changed, 181 insertions(+), 118 deletions(-) diff --git a/examples/lit/package.json b/examples/lit/package.json index 4ac8c70bd..7b6c8f7c5 100644 --- a/examples/lit/package.json +++ b/examples/lit/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@vitest/browser-playwright": "latest", - "jsdom": "^29.1.1", + "jsdom": "^30.1.1", "playwright": "^1.62.1", "vite": "latest", "vitest": "latest" diff --git a/examples/projects/package.json b/examples/projects/package.json index 5a36730dc..25ce729b8 100644 --- a/examples/projects/package.json +++ b/examples/projects/package.json @@ -16,7 +16,7 @@ "@vitejs/plugin-react": "^5.2.0", "@vitest/ui": "latest", "fastify": "^5.12.1", - "jsdom": "^29.1.1", + "jsdom": "^30.1.1", "react": "^19.2.8", "supertest": "^7.2.2", "tsx": "^4.23.13", diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 16d32effc..2d7cfa231 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -187,7 +187,7 @@ "@jridgewell/trace-mapping": "catalog:", "@opentelemetry/api": "^1.9.1", "@sinonjs/fake-timers": "15.4.0", - "@types/jsdom": "^28.0.3", + "@types/jsdom": "^30.0.0", "@types/node": "^24.13.3", "@types/picomatch": "^4.0.3", "@types/prompts": "^2.4.9", @@ -204,7 +204,7 @@ "cac": "catalog:", "flatted": "catalog:", "happy-dom": "^20.12.0", - "jsdom": "^29.1.1", + "jsdom": "^30.1.1", "local-pkg": "^1.2.1", "mime": "^4.1.0", "pathe": "catalog:", diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index 383c9bcaa..86c51a865 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -1,6 +1,7 @@ import type { ConstructorOptions, DOMWindow, VirtualConsole as IVirtualConsole } from 'jsdom' import type { Environment } from '../../types/environment' import type { JSDOMOptions } from '../../types/jsdom-options' +import { createRequire } from 'node:module' import { URL as NodeURL } from 'node:url' import { populateGlobal } from './utils' @@ -292,12 +293,31 @@ interface CompatUtils { makeCompatFormData: (formData: FormData) => FormData } +// jsdom keeps Blob bytes on an internal "impl" object and exposes no synchronous +// public way to read them, so this reaches into its generated bindings +function createBlobImplGetter(window: DOMWindow): (blob: Blob) => any { + const _require = createRequire(import.meta.url) + // jsdom 28.1 moved the generated bindings; jsdom has no "exports" map, so both subpaths resolve + for (const id of [ + 'jsdom/lib/generated/idl/utils.js', + 'jsdom/lib/jsdom/living/generated/utils.js', + ]) { + try { + const { implForWrapper } = _require(id) + if (typeof implForWrapper === 'function') { + return implForWrapper + } + } catch {} + } + // jsdom < 30.1 also stores the impl under an own Symbol("impl") + const implSymbol = Object.getOwnPropertySymbols(new window.Blob())[0] + return (blob) => (blob as any)[implSymbol] +} + +let getBlobImpl: (blob: Blob) => any + function createCompatUtils(window: DOMWindow): CompatUtils { - // this returns a hidden Symbol(impl) - // this is cursed, and jsdom should just implement fetch API itself - const implSymbol = Object.getOwnPropertySymbols( - Object.getOwnPropertyDescriptors(new window.Blob()), - )[0] + getBlobImpl ??= createBlobImplGetter(window) const utils = { window, makeCompatFormData(formData: FormData) { @@ -312,7 +332,12 @@ function createCompatUtils(window: DOMWindow): CompatUtils { return nodeFormData }, makeCompatBlob(blob: Blob) { - const impl = (blob as any)[implSymbol] + const impl = getBlobImpl(blob) + if (!impl) { + throw new TypeError( + 'Vitest cannot read the bytes of a jsdom Blob. This is a Vitest bug, please report it with your jsdom version.', + ) + } // jsdom 28 renamed `_buffer` to `_bytes` return new NodeBlob_([impl._bytes ?? impl._buffer], { type: blob.type }) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a8ffb99b..c8f61069a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,8 +413,8 @@ importers: specifier: workspace:* version: link:../../packages/browser-playwright jsdom: - specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + specifier: ^30.1.1 + version: 30.1.1(@noble/hashes@1.8.0) playwright: specifier: ^1.62.1 version: 1.62.1 @@ -488,8 +488,8 @@ importers: specifier: ^5.12.1 version: 5.12.1 jsdom: - specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + specifier: ^30.1.1 + version: 30.1.1(@noble/hashes@1.8.0) react: specifier: ^19.2.8 version: 19.2.8 @@ -1020,8 +1020,8 @@ importers: specifier: 15.4.0 version: 15.4.0(patch_hash=8fb375421f30746697394082b6fcf4a218fe07a8db36474ac335c48aff32c0cb) '@types/jsdom': - specifier: ^28.0.3 - version: 28.0.3 + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: 24.13.3 version: 24.13.3 @@ -1071,8 +1071,8 @@ importers: specifier: ^20.12.0 version: 20.12.0 jsdom: - specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + specifier: ^30.1.1 + version: 30.1.1(@noble/hashes@1.8.0) local-pkg: specifier: ^1.2.1 version: 1.2.1 @@ -1567,8 +1567,8 @@ importers: specifier: 'catalog:' version: 1.0.0 jsdom: - specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@1.8.0) + specifier: ^30.1.1 + version: 30.1.1(@noble/hashes@1.8.0) vitest: specifier: workspace:* version: link:../../packages/vitest @@ -1611,20 +1611,13 @@ packages: peerDependencies: ajv: '>=8' - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/dom-selector@7.1.1': - resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/css-color@7.1.2': + resolution: {integrity: sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asamuzakjp/dom-selector@9.2.2': + resolution: {integrity: sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} @@ -2283,40 +2276,40 @@ packages: '@canvas/image-data@1.0.0': resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==} - '@csstools/color-helpers@6.0.2': - resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.2.1': - resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + '@csstools/css-calc@3.4.1': + resolution: {integrity: sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==} engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-color-parser@4.1.1': - resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + '@csstools/css-color-parser@4.2.4': + resolution: {integrity: sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==} engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-parser-algorithms@4.0.0': - resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + '@csstools/css-parser-algorithms@4.0.1': + resolution: {integrity: sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==} engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-syntax-patches-for-csstree@1.1.4': - resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: css-tree: optional: true - '@csstools/css-tokenizer@4.0.0': - resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} '@docsearch/css@4.7.0': @@ -2522,6 +2515,15 @@ packages: '@noble/hashes': optional: true + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@faker-js/faker@10.6.0': resolution: {integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -5095,8 +5097,8 @@ packages: '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} - '@types/jsdom@28.0.3': - resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/jsdom@30.0.0': + resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==} '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -5811,8 +5813,8 @@ packages: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} @@ -6950,9 +6952,9 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -7319,11 +7321,11 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - jsdom@29.1.1: - resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + jsdom@30.1.1: + resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.0.0 + canvas: ^3.2.3 peerDependenciesMeta: canvas: optional: true @@ -7600,6 +7602,10 @@ packages: resolution: {integrity: sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==} engines: {node: 20 || >=22} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -9089,9 +9095,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - sync-child-process@1.0.2: resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} engines: {node: '>=16.0.0'} @@ -9248,6 +9251,10 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -9374,8 +9381,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.29.0: - resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} + undici-types@8.11.2: + resolution: {integrity: sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg==} undici@6.27.0: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} @@ -9385,6 +9392,10 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -9746,9 +9757,9 @@ packages: typescript: optional: true - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} wait-port@1.1.0: resolution: {integrity: sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==} @@ -9806,6 +9817,10 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@17.1.2: + resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -10014,25 +10029,20 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - '@asamuzakjp/css-color@5.1.11': + '@asamuzakjp/css-color@7.1.2': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 - '@asamuzakjp/dom-selector@7.1.1': + '@asamuzakjp/dom-selector@9.2.2': dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 + bidi-js: 1.1.0 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} + lru-cache: 11.5.3 '@babel/code-frame@7.29.7': dependencies: @@ -10867,29 +10877,29 @@ snapshots: '@canvas/image-data@1.0.0': {} - '@csstools/color-helpers@6.0.2': {} + '@csstools/color-helpers@6.1.2': {} - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 - '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': dependencies: - '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2)': dependencies: - '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-tokenizer': 4.0.2 - '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 - '@csstools/css-tokenizer@4.0.0': {} + '@csstools/css-tokenizer@4.0.2': {} '@docsearch/css@4.7.0': {} @@ -11022,6 +11032,10 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@exodus/bytes@1.16.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@faker-js/faker@10.6.0': {} '@fastify/ajv-compiler@4.0.5': @@ -12934,12 +12948,12 @@ snapshots: dependencies: '@types/unist': 3.0.2 - '@types/jsdom@28.0.3': + '@types/jsdom@30.0.0': dependencies: '@types/node': 24.13.3 '@types/tough-cookie': 4.0.5 parse5: 8.0.1 - undici-types: 7.29.0 + undici-types: 8.11.2 '@types/jsesc@2.5.1': {} @@ -13803,7 +13817,7 @@ snapshots: basic-ftp@5.3.1: {} - bidi-js@1.0.3: + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 @@ -15183,9 +15197,9 @@ snapshots: readable-stream: 2.3.7 wbuf: 1.7.3 - html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + html-encoding-sniffer@7.0.0(@noble/hashes@1.8.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' @@ -15526,28 +15540,27 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.1.1(@noble/hashes@1.8.0): + jsdom@30.1.1(@noble/hashes@1.8.0): dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@asamuzakjp/dom-selector': 7.1.1 + '@asamuzakjp/css-color': 7.1.2 + '@asamuzakjp/dom-selector': 9.2.2 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) - '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) css-tree: 3.2.1 data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + html-encoding-sniffer: 7.0.0(@noble/hashes@1.8.0) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.4.0 + lru-cache: 11.5.3 parse5: 8.0.1 saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 6.0.1 - undici: 7.25.0 - w3c-xmlserializer: 5.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@1.8.0) + whatwg-url: 17.1.2(@noble/hashes@1.8.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -15777,6 +15790,8 @@ snapshots: lru-cache@11.4.0: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -17749,8 +17764,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} - sync-child-process@1.0.2: dependencies: sync-message-port: 1.1.3 @@ -17904,6 +17917,10 @@ snapshots: dependencies: tldts: 7.0.13 + tough-cookie@6.0.2: + dependencies: + tldts: 7.0.13 + tr46@1.0.1: dependencies: punycode: 2.3.1 @@ -18042,12 +18059,14 @@ snapshots: undici-types@7.18.2: {} - undici-types@7.29.0: {} + undici-types@8.11.2: {} undici@6.27.0: {} undici@7.25.0: {} + undici@8.11.2: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -18469,7 +18488,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - w3c-xmlserializer@5.0.0: + w3c-xmlserializer@6.0.0: dependencies: xml-name-validator: 5.0.0 @@ -18567,6 +18586,14 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@17.1.2(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 diff --git a/test/unit/test/environments/jsdom.spec.ts b/test/unit/test/environments/jsdom.spec.ts index fe41a0998..2f82eda82 100644 --- a/test/unit/test/environments/jsdom.spec.ts +++ b/test/unit/test/environments/jsdom.spec.ts @@ -349,6 +349,17 @@ test('URL.createObjectUrl works properly', () => { }).not.toThrow() }) +test('URL.createObjectURL keeps the Blob bytes', async () => { + const url = URL.createObjectURL(new Blob(['hello world'], { type: 'text/plain' })) + expect(url).toMatch(/^blob:/) + + const response = await fetch(url) + URL.revokeObjectURL(url) + + expect(response.headers.get('content-type')).toBe('text/plain') + expect(await response.text()).toBe('hello world') +}) + test('compat classes preserve their .name property', () => { expect(URL.name).toBe('URL') expect(Request.name).toBe('Request') diff --git a/test/workspaces/package.json b/test/workspaces/package.json index 2fae47fd2..9beec53dd 100644 --- a/test/workspaces/package.json +++ b/test/workspaces/package.json @@ -10,7 +10,7 @@ "devDependencies": { "@jridgewell/remapping": "catalog:", "@vitest/istanbul-lib-coverage": "catalog:", - "jsdom": "^29.1.1", + "jsdom": "^30.1.1", "vitest": "workspace:*" } } -- 2.51.2 From 7c7119cf7c03bfc36c1e4c79f1502868d1207962 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Mon, 28 Sep 2026 19:37:55 +0530 Subject: [PATCH 07/37] fix(vitest): remove root temp dir on close (#11248) --- packages/vitest/src/node/core.ts | 25 +++++++++- .../fixtures/root-tmpdir/basic.test.js | 6 +++ test/node-runner/fixtures/root-tmpdir/lib.js | 1 + test/node-runner/test/root-tmpdir.test.js | 49 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/node-runner/fixtures/root-tmpdir/basic.test.js create mode 100644 test/node-runner/fixtures/root-tmpdir/lib.js create mode 100644 test/node-runner/test/root-tmpdir.test.js diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index b0617c6f5..f78a55280 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -20,6 +20,7 @@ import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage import type { Reporter } from './types/reporter' import type { TestRunResult } from './types/tests' import type { VCSProvider } from './vcs/vcs' +import { rm } from 'node:fs/promises' import os, { tmpdir } from 'node:os' import { SnapshotManager } from '@vitest/snapshot/manager' import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpers' @@ -169,7 +170,7 @@ export class Vitest { /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction /** @internal */ _fsCache!: FileSystemModuleCache - /** @internal */ _tmpDir = join(tmpdir(), nanoid()) + /** @internal */ _tmpDir: string | undefined = join(tmpdir(), nanoid()) /** @internal */ _traces!: Traces /** @internal */ _harness: PluginHarness /** @internal */ _exitTimeout: ReturnType | undefined @@ -252,12 +253,14 @@ export class Vitest { this._fsCache = new FileSystemModuleCache(this) this.snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) this._resolver = new VitestResolver(this.viteConfig.cacheDir, resolved) + // a closed run removes the temp dir, so a restart must allocate a new one + const tmpDir = (this._tmpDir ??= join(tmpdir(), nanoid())) this._fetcher = createFetchModuleFunction( this._resolver, resolved, this._fsCache, this._traces, - this._tmpDir, + tmpDir, ) } @@ -1695,12 +1698,30 @@ export class Vitest { this._checkUnhandledErrors(errors) }) + // the pool is down: forked workers read tmp module copies from disk + await this._clearTmpDir() await this._traces?.finish() })() } return this.closingPromise } + /** + * Removes the root temp directory with the tmp module copies, + * mirroring `TestProject.clearTmpDir()`. The reference is cleared before + * the removal so a repeated close is a no-op. + */ + private async _clearTmpDir(): Promise { + if (!this._tmpDir) { + return + } + const tmpDir = this._tmpDir + this._tmpDir = undefined + try { + await rm(tmpDir, { recursive: true, force: true }) + } catch {} + } + /** * Closes all projects and exit the process * @param force If true, the process will exit immediately after closing the projects. diff --git a/test/node-runner/fixtures/root-tmpdir/basic.test.js b/test/node-runner/fixtures/root-tmpdir/basic.test.js new file mode 100644 index 000000000..f0d1e3897 --- /dev/null +++ b/test/node-runner/fixtures/root-tmpdir/basic.test.js @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { value } from './lib.js' + +test('it passes', () => { + expect(value).toBe(42) +}) diff --git a/test/node-runner/fixtures/root-tmpdir/lib.js b/test/node-runner/fixtures/root-tmpdir/lib.js new file mode 100644 index 000000000..c16d70563 --- /dev/null +++ b/test/node-runner/fixtures/root-tmpdir/lib.js @@ -0,0 +1 @@ +export const value = 42 diff --git a/test/node-runner/test/root-tmpdir.test.js b/test/node-runner/test/root-tmpdir.test.js new file mode 100644 index 000000000..dd67f537f --- /dev/null +++ b/test/node-runner/test/root-tmpdir.test.js @@ -0,0 +1,49 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { createVitest } from 'vitest/node' + +await test('closing Vitest removes the root temp directory holding tmp module copies', async (t) => { + // the root `Vitest._tmpDir` and the per-project `TestProject.tmpDir` are + // bare nanoid dirs under `os.tmpdir()`: redirect it to a sandbox and assert + // it is empty again after a successful run (issue #11224) + const sandbox = await mkdtemp(join(tmpdir(), 'vitest-root-tmpdir-')) + const envNames = ['TMPDIR', 'TEMP', 'TMP'] + const previous = envNames.map((name) => process.env[name]) + for (const name of envNames) { + process.env[name] = sandbox + } + t.after(async () => { + envNames.forEach((name, i) => { + if (previous[i] === undefined) { + delete process.env[name] + } else { + process.env[name] = previous[i] + } + }) + await rm(sandbox, { recursive: true, force: true }) + }) + + // `createVitest` + `start` instead of `startVitest`: the latter already + // closes the instance when the run ends + const vitest = await createVitest({ + root: './fixtures/root-tmpdir', + // the forks pool is the one that reads tmp module copies from disk + pool: 'forks', + reporters: [{}], + }) + t.after(() => vitest.close()) + + await vitest.start() + + // sanity: the run wrote tmp module copies into the root temp dir, so the + // leak this test covers really happened before `close()` + t.assert.ok(vitest._tmpDir, 'root temp dir should be set during a run') + t.assert.ok(existsSync(vitest._tmpDir), 'root temp dir should exist during a run') + + await vitest.close() + + t.assert.deepEqual(await readdir(sandbox), []) +}) -- 2.51.2 From f91db0dfd061e51a56a55dad43d249332b0ebb0a Mon Sep 17 00:00:00 2001 From: Raul Macarie Date: Tue, 29 Sep 2026 09:17:45 +0200 Subject: [PATCH 08/37] fix(ui): split-pane handle overlapping iframe (#11221) --- .../ui/client/components/BrowserIframe.vue | 1 + packages/ui/client/styles/main.css | 5 +- test/browser/specs/projects-ui.test.ts | 47 ---------- test/browser/specs/ui.test.ts | 90 +++++++++++++++++++ 4 files changed, 94 insertions(+), 49 deletions(-) delete mode 100644 test/browser/specs/projects-ui.test.ts create mode 100644 test/browser/specs/ui.test.ts diff --git a/packages/ui/client/components/BrowserIframe.vue b/packages/ui/client/components/BrowserIframe.vue index c610189e3..8772d399a 100644 --- a/packages/ui/client/components/BrowserIframe.vue +++ b/packages/ui/client/components/BrowserIframe.vue @@ -118,6 +118,7 @@ const scale = computed(() => container-type: size; margin-top: 0.5rem; + margin-inline: var(--splitpane-handle-size); } #tester-ui:not([data-ready]) { diff --git a/packages/ui/client/styles/main.css b/packages/ui/client/styles/main.css index 9c6db8f2c..0c26592ea 100644 --- a/packages/ui/client/styles/main.css +++ b/packages/ui/client/styles/main.css @@ -10,6 +10,7 @@ body { --color-text-dark: #ddd; --color-text: var(--color-text-light); --background-color: #e4e4e4; + --splitpane-handle-size: 5px; } html.dark { @@ -128,13 +129,13 @@ html.dark { .splitpanes--vertical > .splitpanes__splitter:before { /* make vertical scroll usable */ left: 0; - right: -10px; + right: calc(var(--splitpane-handle-size) * -1); height: 100%; } .splitpanes--horizontal > .splitpanes__splitter:before { top: 0; - bottom: -10px; + bottom: calc(var(--splitpane-handle-size) * -1); width: 100%; } diff --git a/test/browser/specs/projects-ui.test.ts b/test/browser/specs/projects-ui.test.ts deleted file mode 100644 index b5f764c99..000000000 --- a/test/browser/specs/projects-ui.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from 'vitest' -import { runInlineTests } from '../../test-utils' -import { provider } from '../settings' - -test.runIf(provider.name === 'playwright')( - 'browser ui enabled in projects setup (#10993)', - async () => { - const { stderr, exitCode, testTree } = await runInlineTests( - { - 'basic.test.ts': ` - import { expect, test } from 'vitest' - - test('works', () => { - expect(1 + 1).toBe(2) - }) - `, - }, - { - watch: false, - reporters: 'none', - projects: [ - { - test: { - browser: { - enabled: true, - headless: true, - ui: true, - provider, - instances: [{ browser: 'chromium' }], - }, - }, - }, - ], - }, - ) - - expect(stderr).toBe('') - expect(exitCode).toBe(0) - expect(testTree()).toMatchInlineSnapshot(` - { - "basic.test.ts": { - "works": "passed", - }, - } - `) - }, -) diff --git a/test/browser/specs/ui.test.ts b/test/browser/specs/ui.test.ts new file mode 100644 index 000000000..bf0cb09ec --- /dev/null +++ b/test/browser/specs/ui.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' +import { provider } from '../settings' + +describe.runIf(provider.name === 'playwright')('browser ui', () => { + test('enabled in projects setup (#10993)', async () => { + const { stderr, exitCode, testTree } = await runInlineTests( + { + 'basic.test.ts': /* ts */ ` + import { expect, test } from 'vitest' + + test('works', () => { + expect(1 + 1).toBe(2) + }) + `, + }, + { + watch: false, + reporters: 'none', + projects: [ + { + test: { + browser: { + enabled: true, + headless: true, + ui: true, + provider, + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "works": "passed", + }, + } + `) + }) + + test('split-pane handle does not block iframe interactions (#11170)', async () => { + const { stderr, exitCode, testTree } = await runInlineTests( + { + 'button.test.ts': /* ts */ ` + import { expect, test, vi } from 'vitest' + import { userEvent } from 'vitest/browser' + + test('clicks on button', async () => { + const spy = vi.fn() + const button = document.createElement('button') + button.style = 'width: 5px; height: 10px; padding: 0; border: none;' + button.addEventListener('click', spy) + document.body.appendChild(button) + await userEvent.click(button, { position: { x: 1, y: 1 } }) + + expect(spy).toHaveBeenCalledOnce() + }) + `, + }, + { + watch: true, + reporters: 'none', + browser: { + enabled: true, + headless: true, + ui: true, + provider, + instances: [{ browser: 'chromium' }], + viewport: { width: 50, height: 50 }, + }, + }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(testTree()).toMatchInlineSnapshot(` + { + "button.test.ts": { + "clicks on button": "passed", + }, + } + `) + }) +}) -- 2.51.2 From b24585f08f2ea267746a2d6ca0e43edcbb29726f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 29 Sep 2026 16:20:58 +0900 Subject: [PATCH 09/37] fix: don't retry when `test.fails` expectedly failed (#11219) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Codex (GPT-6) --- packages/vitest/src/runtime/runner/run.ts | 30 ++-- test/e2e/test/repeats.test.ts | 33 +++++ test/e2e/test/retry.test.ts | 168 +++++++++++++++++++++- test/unit/test/on-finished.test.ts | 12 +- test/unit/test/repeats.test.ts | 2 +- test/unit/test/retry.test.ts | 2 +- 6 files changed, 223 insertions(+), 24 deletions(-) diff --git a/packages/vitest/src/runtime/runner/run.ts b/packages/vitest/src/runtime/runner/run.ts index 3fcf0910f..6e8299535 100644 --- a/packages/vitest/src/runtime/runner/run.ts +++ b/packages/vitest/src/runtime/runner/run.ts @@ -602,6 +602,7 @@ async function runTest(test: Test, runner: VitestRunner): Promise { test.result.state = 'run' as TaskState const retry = getRetryCount(test.retry) for (let retryCount = 0; retryCount <= retry; retryCount++) { + const attemptErrorsStart = test.result.errors?.length ?? 0 let beforeEachCleanups: unknown[] = [] // fixtureCheckpoint is passed by callAroundEachHooks - it represents the count // of fixture cleanup functions AFTER all aroundEach fixtures have been resolved @@ -706,6 +707,23 @@ async function runTest(test: Test, runner: VitestRunner): Promise { return } + // if test is marked to be failed, flip the result unless `TestSyntaxError` is present + if (test.fails) { + if (test.result.state === 'pass') { + const error = processError(new Error('Expect test to fail')) + test.result.state = 'fail' + test.result.errors ??= [] + test.result.errors.push(error) + } else if ( + !test.result.errors?.slice(attemptErrorsStart).some((e) => e.__vitest_test_syntax_error__) + ) { + test.result.state = 'pass' + test.result.errors?.splice(attemptErrorsStart) + if (!test.result.errors?.length) { + test.result.errors = undefined + } + } + } if (test.result.state === 'pass') { break } @@ -739,18 +757,6 @@ async function runTest(test: Test, runner: VitestRunner): Promise { test.result.state = 'fail' } - // if test is marked to be failed, flip the result unless `TestSyntaxError` is present - if (test.fails) { - if (test.result.state === 'pass') { - const error = processError(new Error('Expect test to fail')) - test.result.state = 'fail' - test.result.errors = [error] - } else if (!test.result.errors?.some((e) => e.__vitest_test_syntax_error__)) { - test.result.state = 'pass' - test.result.errors = undefined - } - } - cleanupRunningTest() setCurrentTest(undefined) diff --git a/test/e2e/test/repeats.test.ts b/test/e2e/test/repeats.test.ts index 297916168..ab2593bb3 100644 --- a/test/e2e/test/repeats.test.ts +++ b/test/e2e/test/repeats.test.ts @@ -134,3 +134,36 @@ test('onTestFailed runs only for failed repeats', async () => { } `) }) +test('expected failures are evaluated for each repeat', async () => { + const { errorTree } = await runInlineTests({ + 'repeats.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const runs = [0, 0, 0, 0, 0] + + it.fails('alternates passing and failing repeats', { repeats: 4 }, ({ task }) => { + const repeatCount = task.result.repeatCount + runs[repeatCount]++ + if (repeatCount % 2 === 1) { + throw new Error('repeat ' + repeatCount + ' failed') + } + }) + + afterAll(() => { + expect(runs).toEqual([1, 1, 1, 1, 1]) + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "alternates passing and failing repeats": [ + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + ], + }, + } + `) +}) diff --git a/test/e2e/test/retry.test.ts b/test/e2e/test/retry.test.ts index 7fbfc8274..17a29fc44 100644 --- a/test/e2e/test/retry.test.ts +++ b/test/e2e/test/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { runVitest } from '../../test-utils' +import { runInlineTests, runVitest } from '../../test-utils' function run(testNamePattern: string) { return runVitest({ @@ -25,3 +25,169 @@ describe('retry', () => { expect(stdout).toContain('1 failed') }) }) + +test('expected failures stop retrying after a failed assertion', async () => { + const { stderr, errorTree } = await runInlineTests({ + 'fails.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const runs = { + immediate: 0, + passesThenFails: 0, + repeats: [0, 0, 0], + } + + it.fails('fails immediately', { retry: 2 }, () => { + runs.immediate++ + expect(1).toBe(2) + }) + + it.fails('passes then fails', { retry: 2 }, () => { + runs.passesThenFails++ + expect(runs.passesThenFails).toBe(1) + }) + + it.fails('repeats', { retry: 2, repeats: 2 }, ({ task }) => { + runs.repeats[task.result.repeatCount]++ + expect(1).toBe(2) + }) + + afterAll(() => { + expect(runs).toEqual({ + immediate: 1, + passesThenFails: 2, + repeats: [1, 1, 1], + }) + }) + `, + }) + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "fails.test.js": { + "fails immediately": "passed", + "passes then fails": "passed", + "repeats": "passed", + }, + } + `) +}) + +test('expected failures exhaust retries in every repeat when assertions pass', async () => { + const { errorTree } = await runInlineTests({ + 'fails.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const runs = [[0], [0, 0], [0, 0, 0]] + + for (const repeats of [0, 1, 2]) { + it.fails('unexpected pass with ' + repeats + ' repeats', { retry: 2, repeats }, ({ task }) => { + runs[repeats][task.result.repeatCount]++ + expect(1).toBe(1) + }) + } + + afterAll(() => { + expect(runs).toEqual([ + [3], + [3, 3], + [3, 3, 3], + ]) + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "fails.test.js": { + "unexpected pass with 0 repeats": [ + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + ], + "unexpected pass with 1 repeats": [ + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + ], + "unexpected pass with 2 repeats": [ + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + "Expect test to fail", + ], + }, + } + `) +}) + +test('expected failures can recover through a retry in every repeat', async () => { + const { stderr, errorTree } = await runInlineTests({ + 'repeats.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const attempts = [0, 0, 0] + it.fails('recovers', { repeats: 2, retry: 2 }, ({ task }) => { + const attempt = attempts[task.result.repeatCount]++ + if (attempt > 0) { + throw new Error('attempt failed') + } + }) + + afterAll(() => { + expect(attempts).toEqual([2, 2, 2]) + }) + `, + }) + + expect(stderr).toBe('') + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "recovers": "passed", + }, + } + `) +}) + +test('syntax errors remain failures after successful repeats', async () => { + const { errorTree } = await runInlineTests({ + 'repeats.test.js': ` + import { afterAll, expect, it } from 'vitest' + + const runs = [0, 0] + + it.fails('syntax error', { repeats: 1, retry: 1 }, ({ task }) => { + runs[task.result.repeatCount]++ + if (task.result.repeatCount === 0) { + expect(1).toMatchInlineSnapshot('1') + } + expect(1).toBe(2) + }) + + afterAll(() => { + expect(runs).toEqual([2, 1]) + }) + `, + }) + + expect(errorTree()).toMatchInlineSnapshot(` + { + "repeats.test.js": { + "syntax error": [ + "'toMatchInlineSnapshot' cannot be used with 'test.fails'", + "'toMatchInlineSnapshot' cannot be used with 'test.fails'", + ], + }, + } + `) +}) diff --git a/test/unit/test/on-finished.test.ts b/test/unit/test/on-finished.test.ts index 99c1e88cc..f105cf8a0 100644 --- a/test/unit/test/on-finished.test.ts +++ b/test/unit/test/on-finished.test.ts @@ -105,9 +105,7 @@ describe('repeats fail', () => { state.push(`${tag}fail`) }) - if (t.task.result?.repeatCount === 1) { - throw new Error('fail') - } + throw new Error('fail') }) it('assert', () => { @@ -115,11 +113,13 @@ describe('repeats fail', () => { [ "(0, 0) run", "(0, 0) finish", + "(0, 0) fail", "(0, 1) run", "(0, 1) finish", "(0, 1) fail", "(0, 2) run", "(0, 2) finish", + "(0, 2) fail", ] `) }) @@ -186,12 +186,6 @@ describe('retry fail', () => { "(0, 0) run", "(0, 0) finish", "(0, 0) fail", - "(1, 0) run", - "(1, 0) finish", - "(1, 0) fail", - "(2, 0) run", - "(2, 0) finish", - "(2, 0) fail", ] `) }) diff --git a/test/unit/test/repeats.test.ts b/test/unit/test/repeats.test.ts index 14f44e6af..16a9ef497 100644 --- a/test/unit/test/repeats.test.ts +++ b/test/unit/test/repeats.test.ts @@ -40,7 +40,7 @@ const retryNumbers: number[] = [] describe('testing repeats with retry', () => { describe('normal test', () => { - const result = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + const result = [1, 1, 1, 1, 1] test.fails('test 1', { repeats: 4, retry: 1 }, () => { retryNumbers.push(1) expect(1).toBe(2) diff --git a/test/unit/test/retry.test.ts b/test/unit/test/retry.test.ts index dde98ac82..e5b0165c2 100644 --- a/test/unit/test/retry.test.ts +++ b/test/unit/test/retry.test.ts @@ -20,7 +20,7 @@ it('retry test fails', { retry: 10 }, () => { it('result', () => { expect(count1).toEqual(3) - expect(count2).toEqual(2) + expect(count2).toEqual(1) expect(count3).toEqual(3) }) -- 2.51.2 From 38f98855fa9cd7fd376afb84094eba0fda256a74 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 29 Sep 2026 09:52:13 +0200 Subject: [PATCH 10/37] fix(cache): revalidate imports of cached modules (#11381) --- .../vitest/src/node/cache/fsModuleCache.ts | 16 +- .../src/node/environments/fetchModule.ts | 130 +++++++--- packages/vitest/src/node/vite.ts | 2 +- .../test/fs-module-cache-invalidation.test.ts | 227 ++++++++++++++++++ 4 files changed, 342 insertions(+), 33 deletions(-) create mode 100644 test/e2e/test/fs-module-cache-invalidation.test.ts diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 65d5f0207..2349f27c9 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -42,7 +42,7 @@ export class FileSystemModuleCache { private rootCache: string private metadataFilePath: string - private version = '1.0.0-beta.7' + private version = '1.0.0-beta.8' private fsCacheRoots = new WeakMap() private fsEnvironmentHashMap = new WeakMap() private fsCacheKeyGenerators = new Set() @@ -142,7 +142,7 @@ export class FileSystemModuleCache { url: meta.url, file: meta.file, code, - importedUrls: meta.importedUrls, + imports: meta.imports, mappings: meta.mappings, moduleType: meta.moduleType, deps: meta.deps, @@ -155,7 +155,7 @@ export class FileSystemModuleCache { cachedFilePath: string, fetchResult: VitestFetchResult, transformResult: TransformResult | null, - importedUrls: string[] = [], + imports: CachedModuleImports = { urls: [], ids: {} }, mappings: boolean = false, ): Promise { if ('code' in fetchResult) { @@ -163,7 +163,7 @@ export class FileSystemModuleCache { file: fetchResult.file, id: fetchResult.id, url: fetchResult.url, - importedUrls, + imports, mappings, moduleType: fetchResult.moduleType, deps: transformResult?.deps, @@ -407,13 +407,19 @@ export interface CachedInlineModuleMeta { file: string | null code: string mappings: boolean - importedUrls: string[] + imports: CachedModuleImports moduleType?: ModuleType deps?: string[] dynamicDeps?: string[] staticMocks?: StaticMockCall[] | null } +export interface CachedModuleImports { + urls: string[] + // resolved ids that differ from the id derived from the url + ids: Record +} + /** * Generate a unique cache identifier. * diff --git a/packages/vitest/src/node/environments/fetchModule.ts b/packages/vitest/src/node/environments/fetchModule.ts index 5eeaad3e4..f58fe5f82 100644 --- a/packages/vitest/src/node/environments/fetchModule.ts +++ b/packages/vitest/src/node/environments/fetchModule.ts @@ -1,6 +1,12 @@ import type { Span } from '@opentelemetry/api' import type { StaticMockCall } from '@vitest/mocker/node' -import type { DevEnvironment, EnvironmentModuleNode, Rollup, TransformResult } from 'vite' +import type { + DevEnvironment, + EnvironmentModuleNode, + ResolvedConfig as ViteResolvedConfig, + Rollup, + TransformResult, +} from 'vite' import type { FetchFunctionOptions, FetchResult } from 'vite/module-runner' import type { FetchCachedFileSystemResult, @@ -9,17 +15,23 @@ import type { VitestFetchResult, } from '../../types/general' import type { OTELCarrier, Traces } from '../../utils/traces' -import type { FileSystemModuleCache } from '../cache/fsModuleCache' +import type { + CachedInlineModuleMeta, + CachedModuleImports, + FileSystemModuleCache, +} from '../cache/fsModuleCache' import type { VitestResolver } from '../resolver' import type { ResolvedConfig } from '../types/config' import { existsSync, mkdirSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { isExternalUrl, unwrapId } from '@vitest/utils/helpers' import { join } from 'pathe' +import c from 'tinyrainbow' import { fetchModule } from 'vite' import { createDebugger } from '../../utils/debugger' import { hash } from '../hash' import { detectModuleType } from '../resolver' +import { fsPathFromId } from '../vite' import { normalizeResolvedIdToUrl } from './normalizeUrl' const debugFs = createDebugger('vitest:cache:fs') @@ -164,7 +176,7 @@ class ModuleFetcher { moduleGraphModule, options, ) - const importedUrls = this.getSerializedImports(moduleGraphModule) + const imports = this.getCachedImports(environment, moduleGraphModule) const map = moduleGraphModule.transformResult?.map const mappings = map && !('version' in map) && map.mappings === '' @@ -172,7 +184,7 @@ class ModuleFetcher { result, cachePath, moduleGraphModule.transformResult, - importedUrls, + imports, !!mappings, ) // remember where the code is stored on disk so that repeat fetches and the @@ -184,11 +196,19 @@ class ModuleFetcher { return cachedResult } - // we need this for UI to be able to show a module graph - private getSerializedImports(node: EnvironmentModuleNode): string[] { - const imports: string[] = [] - node.importedModules.forEach((importer) => { - imports.push(importer.url) + private getCachedImports( + environment: DevEnvironment, + node: EnvironmentModuleNode, + ): CachedModuleImports { + const imports: CachedModuleImports = { urls: [], ids: {} } + node.importedModules.forEach(({ url, id }) => { + if (id == null) { + return + } + imports.urls.push(url) + if (id !== urlToId(environment, url)) { + imports.ids[url] = id + } }) return imports } @@ -245,12 +265,7 @@ class ModuleFetcher { environment: DevEnvironment, moduleGraphModule: EnvironmentModuleNode, ): Promise { - if ( - moduleGraphModule.file && - // \x00 is a virtual file convention - !moduleGraphModule.file.startsWith('\x00') && - !moduleGraphModule.file.startsWith('virtual:') - ) { + if (moduleGraphModule.file && !isVirtualFile(moduleGraphModule.file)) { const result = await this.readFileConcurrently(moduleGraphModule.file) if (result != null) { return result @@ -302,6 +317,11 @@ class ModuleFetcher { return } + const importedModules = await this.resolveCachedImports(environment, cachedModule) + if (!importedModules) { + return + } + // keep the module graph in sync let map: Rollup.SourceMap | null | { mappings: '' } = extractSourceMap(cachedModule.code) if (map && cachedModule.file) { @@ -331,15 +351,10 @@ class ModuleFetcher { } } - await Promise.all( - cachedModule.importedUrls.map(async (url) => { - const moduleNode = await environment.moduleGraph.ensureEntryFromUrl(url).catch(() => null) - if (moduleNode) { - moduleNode.importers.add(moduleGraphModule) - moduleGraphModule.importedModules.add(moduleNode) - } - }), - ) + for (const moduleNode of importedModules) { + moduleNode.importers.add(moduleGraphModule) + moduleGraphModule.importedModules.add(moduleNode) + } return { cached: true as const, @@ -352,6 +367,43 @@ class ModuleFetcher { } } + // the cached code imports the urls that Vite resolved when the module was + // transformed, so the entry is stale once any of them points elsewhere + private async resolveCachedImports( + environment: DevEnvironment, + cachedModule: CachedInlineModuleMeta, + ): Promise { + const safeModulePaths = getSafeModulePaths(environment) + const { urls, ids } = cachedModule.imports + const importedModules = await Promise.all( + urls.map(async (url) => { + const id = Object.hasOwn(ids, url) ? ids[url] : urlToId(environment, url) + const moduleNode = await environment.moduleGraph.ensureEntryFromUrl(url).catch(() => null) + const stale = + !moduleNode || + moduleNode.id !== id || + // the resolver trusts /@fs/ urls without checking that the file exists + (url.startsWith('/@fs/') && moduleNode.file != null && !existsSync(moduleNode.file)) + if (stale) { + debugFs?.( + `${c.red('[stale]')} ${cachedModule.id} imports ${url}, which no longer resolves to ${id}`, + ) + return null + } + // import analysis marks out-of-root imports as safe to load from + // outside `server.fs.allow`; a cached importer never goes through it + if (url.startsWith('/@fs/') && moduleNode.file) { + safeModulePaths?.add(moduleNode.file) + } + return moduleNode + }), + ) + const resolved = importedModules.filter((moduleNode) => moduleNode != null) + if (resolved.length === importedModules.length) { + return resolved + } + } + private async fetchAndProcess( environment: DevEnvironment, url: string, @@ -382,7 +434,7 @@ class ModuleFetcher { } private sourceLoader(file: string | null): (() => Promise) | undefined { - if (!file || file.startsWith('\x00') || file.startsWith('virtual:')) { + if (!file || isVirtualFile(file)) { return undefined } return () => this.readFileConcurrently(file) @@ -414,7 +466,7 @@ class ModuleFetcher { result: FetchResult, cachePath: string, transformResult: TransformResult | null, - importedUrls: string[] = [], + imports?: CachedModuleImports, mappings = false, ): Promise { const returnResult = 'code' in result ? getCachedResult(result, cachePath) : result @@ -424,7 +476,7 @@ class ModuleFetcher { } const savePromise = this.fsCache - .saveCachedModule(cachePath, result, transformResult, importedUrls, mappings) + .saveCachedModule(cachePath, result, transformResult, imports, mappings) .then(() => returnResult) .catch((error) => { debugFs?.(`failed to cache ${cachePath}, serving it inline: ${error}`) @@ -455,6 +507,30 @@ class ModuleFetcher { } } +// \x00 is a virtual file convention +function isVirtualFile(file: string): boolean { + return file.startsWith('\x00') || file.startsWith('virtual:') +} + +// inverts the url that import analysis writes for a resolved id +function urlToId(environment: DevEnvironment, url: string): string { + if (url.startsWith('/@fs/')) { + return fsPathFromId(url) + } + if (url[0] === '/') { + return environment.config.root + url + } + return url +} + +// Vite keeps the set out of its public types +function getSafeModulePaths(environment: DevEnvironment): Set | undefined { + const config = environment.getTopLevelConfig() as ViteResolvedConfig & { + safeModulePaths?: Set + } + return config.safeModulePaths +} + export interface VitestFetchFunction { ( url: string, diff --git a/packages/vitest/src/node/vite.ts b/packages/vitest/src/node/vite.ts index c529e5f3b..88479ff50 100644 --- a/packages/vitest/src/node/vite.ts +++ b/packages/vitest/src/node/vite.ts @@ -47,7 +47,7 @@ export function isFileServingAllowed( const FS_PREFIX = '/@fs/' const VOLUME_RE = /^[A-Z]:/i -function fsPathFromId(id: string): string { +export function fsPathFromId(id: string): string { const fsPath = normalizePath(id.startsWith(FS_PREFIX) ? id.slice(FS_PREFIX.length) : id) return fsPath[0] === '/' || VOLUME_RE.test(fsPath) ? fsPath : `/${fsPath}` } diff --git a/test/e2e/test/fs-module-cache-invalidation.test.ts b/test/e2e/test/fs-module-cache-invalidation.test.ts new file mode 100644 index 000000000..d6696ad9d --- /dev/null +++ b/test/e2e/test/fs-module-cache-invalidation.test.ts @@ -0,0 +1,227 @@ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'pathe' +import { expect, test } from 'vitest' +import { runInlineTests, runVitest, useTmpFS } from '#test-utils' + +function cacheTimestamps(cachePath: string) { + return readdirSync(cachePath).map((file) => [file, statSync(join(cachePath, file)).mtimeMs]) +} + +// The import check must not report a false miss, or the cache is rewritten on +// every run: virtual modules resolve to ids that are not files, and an id may +// look like an absolute path without being one. +test('an unchanged module graph is served from the cache', async () => { + const cold = await runInlineTests({ + 'vitest.config.js': /* js */ ` + export default { + plugins: [ + { + name: 'answer', + resolveId(id) { + if (id === 'virtual:answer') return '\\0virtual:answer' + if (id === 'virtual:base' || id === '/virtual/base') return '/virtual/base' + }, + load(id) { + if (id === '\\0virtual:answer') return 'export const answer = 42' + if (id === '/virtual/base') return 'export const base = 0' + }, + }, + ], + test: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + } + `, + 'src/a.ts': /* ts */ ` + import { answer } from 'virtual:answer' + import { base } from 'virtual:base' + import { value } from './b' + export const sum = answer + base + value + `, + 'src/b.ts': `export const value = 1`, + 'src/a.test.ts': /* ts */ ` + import { expect, it } from 'vitest' + import { sum } from './a' + it('reads it', () => { + expect(sum).toBe(43) + }) + `, + }) + expect(cold.stderr).toBe('') + expect(cold.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + await cold.ctx?.close() + + const cachePath = join(cold.root, 'node_modules/.vitest-fs-cache') + const timestamps = cacheTimestamps(cachePath) + expect(timestamps.length).toBeGreaterThan(1) + + const warm = await runVitest({ root: cold.root }) + expect(warm.stderr).toBe('') + expect(warm.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + expect(cacheTimestamps(cachePath)).toEqual(timestamps) +}) + +// A cached transform embeds the resolved URLs of its imports, so it has to be +// dropped when those imports no longer resolve to the same modules, even if +// the importer's own source did not change (a branch switch renames a file). +test('a cached importer is re-transformed after its dependency is renamed', async () => { + const structure = { + 'src/a.ts': `export { value } from './b'`, + 'src/b.ts': `export const value = 1`, + 'src/a.test.ts': /* ts */ ` + import { expect, it } from 'vitest' + import { value } from './a' + it('reads it', () => { + expect(value).toBe(1) + }) + `, + } + const config = { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + } + + const cold = await runInlineTests(structure, config) + expect(cold.stderr).toBe('') + expect(cold.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + await cold.ctx?.close() + + cold.fs.renameFile('src/b.ts', 'src/b.tsx') + + const warm = await runVitest({ root: cold.root, ...config }) + expect(warm.stderr).toBe('') + expect(warm.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) +}) + +// Vite only lets a client environment read files outside `server.fs.allow` +// when import analysis saw them being imported. A cached importer skips import +// analysis, so its out-of-root imports have to be registered by the cache. +test('a changed dependency outside the root loads after its importer is served from the cache', async () => { + const fs = useTmpFS( + { + 'app/package.json': JSON.stringify({ name: 'app', type: 'module' }), + // keeps the workspace root at app/, so shared/ is outside `server.fs.allow` + 'app/pnpm-workspace.yaml': '', + 'app/vitest.config.mjs': /* js */ ` + export default { + test: { + environment: 'jsdom', + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + } + `, + 'app/src/a.test.ts': /* ts */ ` + import { expect, it } from 'vitest' + import { value } from '../../shared/index' + it('reads it', () => { + expect(value).toBe(1) + }) + `, + 'shared/index.ts': `export * from './b'`, + 'shared/b.ts': `export const value = 1`, + }, + false, + ) + const root = join(fs.root, 'app') + + const cold = await runVitest({ root }) + expect(cold.stderr).toBe('') + expect(cold.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + await cold.ctx?.close() + + fs.editFile('shared/b.ts', (content) => `${content}\nexport const probe = 2\n`) + + const warm = await runVitest({ root }) + expect(warm.stderr).toBe('') + expect(warm.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) +}) + +// Imports from outside the root are rewritten to /@fs/ urls, which the +// resolver accepts without checking that the file still exists. +test('a cached importer is re-transformed after a dependency outside the root is renamed', async () => { + const fs = useTmpFS( + { + 'app/package.json': JSON.stringify({ name: 'app', type: 'module' }), + 'app/vitest.config.mjs': /* js */ ` + export default { + test: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + } + `, + 'app/src/a.test.ts': /* ts */ ` + import { expect, it } from 'vitest' + import { value } from '../../shared/index' + it('reads it', () => { + expect(value).toBe(1) + }) + `, + 'shared/index.ts': `export * from './b'`, + 'shared/b.ts': `export const value = 1`, + }, + false, + ) + const root = join(fs.root, 'app') + + const cold = await runVitest({ root }) + expect(cold.stderr).toBe('') + expect(cold.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + await cold.ctx?.close() + + fs.renameFile('shared/b.ts', 'shared/b.tsx') + + const warm = await runVitest({ root }) + expect(warm.stderr).toBe('') + expect(warm.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) +}) -- 2.51.2 From 92ba7fc1df16a4fa5bbee3f198c582fbd56689d8 Mon Sep 17 00:00:00 2001 From: EthanZHOU Date: Tue, 29 Sep 2026 17:21:23 +0800 Subject: [PATCH 11/37] fix: scope cache key generators to projects (fix #11281) (#11301) --- .../vitest/src/node/cache/fsModuleCache.ts | 13 +++-- packages/vitest/src/node/core.ts | 5 +- packages/vitest/src/node/types/plugin.ts | 2 +- test/e2e/test/caching.test.ts | 58 ++++++++++++++++++- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/vitest/src/node/cache/fsModuleCache.ts b/packages/vitest/src/node/cache/fsModuleCache.ts index 2349f27c9..44677d2f4 100644 --- a/packages/vitest/src/node/cache/fsModuleCache.ts +++ b/packages/vitest/src/node/cache/fsModuleCache.ts @@ -45,7 +45,7 @@ export class FileSystemModuleCache { private version = '1.0.0-beta.8' private fsCacheRoots = new WeakMap() private fsEnvironmentHashMap = new WeakMap() - private fsCacheKeyGenerators = new Set() + private fsCacheKeyGenerators = new WeakMap>() private warnedDeprecatedIgnore = new Set() // this exists only to avoid the perf. cost of reading a file and generating a hash again // surprisingly, on some machines this has negligible effect @@ -62,8 +62,13 @@ export class FileSystemModuleCache { this.metadataFilePath = join(this.rootCache, METADATA_FILE) } - public defineCacheKeyGenerator(callback: CacheKeyIdGenerator): void { - this.fsCacheKeyGenerators.add(callback) + public defineCacheKeyGenerator(config: ResolvedConfig, callback: CacheKeyIdGenerator): void { + let generators = this.fsCacheKeyGenerators.get(config) + if (!generators) { + generators = new Set() + this.fsCacheKeyGenerators.set(config, generators) + } + generators.add(callback) } // A plugin can exclude itself from the cache key via `api.vitest.ignoreFsModuleCache`. @@ -224,7 +229,7 @@ export class FileSystemModuleCache { let hashString = '' - for (const generator of this.fsCacheKeyGenerators) { + for (const generator of this.fsCacheKeyGenerators.get(vitestConfig) || []) { const result = generator({ environment, id, sourceCode: fileContent }) if (typeof result === 'string') { hashString += result diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index f78a55280..4f2659f5f 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -396,7 +396,8 @@ export class Vitest { project, vitest: this, injectTestProjects: this.injectTestProject, - defineCacheKeyGenerator: (callback) => this._fsCache.defineCacheKeyGenerator(callback), + defineCacheKeyGenerator: (callback) => + this._fsCache.defineCacheKeyGenerator(project.config, callback), /** * @deprecated Use `defineCacheKeyGenerator` instead. */ @@ -407,7 +408,7 @@ export class Vitest { '`experimental_defineCacheKeyGenerator` is deprecated. Use `defineCacheKeyGenerator` instead.', ) } - this._fsCache.defineCacheKeyGenerator(callback) + this._fsCache.defineCacheKeyGenerator(project.config, callback) }, }), ) diff --git a/packages/vitest/src/node/types/plugin.ts b/packages/vitest/src/node/types/plugin.ts index ef5353a69..aeadf74bf 100644 --- a/packages/vitest/src/node/types/plugin.ts +++ b/packages/vitest/src/node/types/plugin.ts @@ -10,7 +10,7 @@ export interface VitestPluginContext { config: TestProjectConfiguration | TestProjectConfiguration[], ) => Promise /** - * Define a generator that will be applied before hashing the cache key. + * Define a generator that will be applied before hashing this project's cache key. * * Use this to make sure Vitest generates correct hash. It is a good idea * to define this function if your plugin can be registered with different options. diff --git a/test/e2e/test/caching.test.ts b/test/e2e/test/caching.test.ts index a590e5d98..fd65ef882 100644 --- a/test/e2e/test/caching.test.ts +++ b/test/e2e/test/caching.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { runVitest, useFS } from '../../test-utils' +import { runInlineTests, runVitest, useFS } from '../../test-utils' test("if file has import.meta.glob, it's not cached", async () => { const { createFile } = useFS( @@ -138,6 +138,62 @@ test('if cache key generator is defined, the hash is valid', async () => { `) }) +test.each([ + ['foo', 'bar'], + ['bar', 'foo'], +])('cache key generators are scoped to projects (%s, %s)', async (first, second) => { + const cold = await runInlineTests({ + 'vitest.config.js': ` + import { defineConfig } from 'vitest/config' + export default defineConfig({ + test: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + projects: ${JSON.stringify([first, second])}.map(name => ({ + plugins: [{ + name: 'replacer', + configureVitest({ defineCacheKeyGenerator }) { + defineCacheKeyGenerator(() => name) + }, + transform(code, id) { + if (id.endsWith('/common.js')) { + return code.replace('PLACEHOLDER', name) + } + }, + }], + test: { name, include: [name + '.test.js'] }, + })), + }, + }) + `, + 'common.js': `export const value = 'PLACEHOLDER'`, + 'foo.test.js': ` + import { expect, test } from 'vitest' + import { value } from './common.js' + test('project value', () => expect(value).toBe('foo')) + `, + 'bar.test.js': ` + import { expect, test } from 'vitest' + import { value } from './common.js' + test('project value', () => expect(value).toBe('bar')) + `, + }) + const warm = await runVitest({ root: cold.root }) + for (const run of [cold, warm]) { + expect(run.stderr).toBe('') + expect(run.errorTree()).toMatchInlineSnapshot(` + { + "bar.test.js": { + "project value": "passed", + }, + "foo.test.js": { + "project value": "passed", + }, + } + `) + } +}) + test('if cache key generator bails out, the file is not cached', async () => { process.env.REPLACED = 'value1' -- 2.51.2 From 7c36748fad1eae9687312f2f7ceadce6ec88b5df Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 29 Sep 2026 11:21:55 +0200 Subject: [PATCH 12/37] fix(browser): ignore page crash while cancelling (#11386) --- packages/browser-playwright/src/playwright.ts | 4 +- packages/vitest/src/node/pools/browser.ts | 7 +- test/e2e/test/scoped-fixtures.test.ts | 74 +++++++++---------- 3 files changed, 45 insertions(+), 40 deletions(-) diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 9cbe5b1d7..cbe09c45b 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -32,7 +32,7 @@ import { defineBrowserProvider } from '@vitest/browser' import { createManualModuleSource } from '@vitest/mocker/node' import { resolve } from 'pathe' import c from 'tinyrainbow' -import { createDebugger, isCSSRequest } from 'vitest/node' +import { BrowserConnectionError, createDebugger, isCSSRequest } from 'vitest/node' import commands from './commands' import { distRoot } from './constants' @@ -643,7 +643,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { debug?.('[%s][%s] the page crashed', sessionId, this.browserName) const session = this.project.vitest._browserSessions.getSession(sessionId) session?.fail( - new Error( + new BrowserConnectionError( `The ${this.browserName} page crashed while running tests. This can happen if the browser ran out of memory.`, ), ) diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 3b541e2e8..84811b67a 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -244,7 +244,12 @@ class BrowserPool { } public reject(error: Error): void { - this._promise?.reject(error) + // if user cancels the test run manually, ignore the error and exit gracefully + if (this.project.vitest.isCancelling && error instanceof BrowserConnectionError) { + this._promise?.resolve() + } else { + this._promise?.reject(error) + } this._promise = undefined this.cancel() } diff --git a/test/e2e/test/scoped-fixtures.test.ts b/test/e2e/test/scoped-fixtures.test.ts index ce3cb2b12..14357ffec 100644 --- a/test/e2e/test/scoped-fixtures.test.ts +++ b/test/e2e/test/scoped-fixtures.test.ts @@ -216,8 +216,8 @@ test('test fixture cannot import from worker fixture', async () => { 2| const extendedTest = it.extend({ | ^ 3| local: ({}, use) => use("local"), - 4| worker: [ - ❯ basic.test.ts:11:1 + 4| worker: [({ local }, use) => use(local), { scope: "worker" }] + ❯ basic.test.ts:8:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -293,8 +293,8 @@ test('worker fixture can import a static value from test fixture', async () => { 5| export const extendedTest = (() => it.extend({ | ^ 6| local: "local", - 7| worker: [ - ❯ test.js:11:4 + 7| worker: [({ local }, use) => use(local), { scope: "worker" }] + ❯ test.js:8:4 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -334,8 +334,8 @@ test('file fixture cannot import a static value from test fixture', async () => 5| export const extendedTest = (() => it.extend({ | ^ 6| local: "local", - 7| file: [ - ❯ test.js:11:4 + 7| file: [({ local }, use) => use(local), { scope: "file" }] + ❯ test.js:8:4 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -772,11 +772,11 @@ test('beforeAll/afterAll hooks throw error when accessing test-scoped fixtures', ❯ basic.test.ts:4:16 2| import { extendedTest, expect, expectTypeOf, describe, beforeAll, afte… 3| const results = await (({ extendedTest }) => { - 4| extendedTest.beforeAll(({ + 4| extendedTest.beforeAll( | ^ - 5| fileValue: _fileValue, - 6| // @ts-expect-error - test-scoped fixtures are not available in be… - ❯ basic.test.ts:12:1 + 5| ({ + 6| fileValue: _fileValue, + ❯ basic.test.ts:14:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -813,11 +813,11 @@ test('global beforeAll/afterAll hooks throw error when accessing any fixture', a ❯ basic.test.ts:4:3 2| import { extendedTest, expect, expectTypeOf, describe, beforeAll, afte… 3| const results = await (({ extendedTest, beforeAll }) => { - 4| beforeAll(({ + 4| beforeAll(({ fileValue }) => { | ^ - 5| fileValue - 6| }) => { - ❯ basic.test.ts:11:1 + 5| console.log(">> fixture | beforeAll | file:", fileValue); + 6| }); + ❯ basic.test.ts:9:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1466,8 +1466,8 @@ test('file fixture cannot access test fixture at runtime', async () => { 2| const extendedTest = it.extend({ | ^ 3| testFixture: ({}, use) => use("test"), - 4| fileFixture: [ - ❯ basic.test.ts:11:1 + 4| fileFixture: [({ testFixture }, use) => use(testFixture.length), {… + ❯ basic.test.ts:8:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1645,9 +1645,9 @@ describe('scoped fixtures type safety', () => { 4| export const expectTypeOf = globalThis.expectTypeOf 5| export const extendedTest = (({ log, expectTypeOf: _expectTypeOf }) =>… | ^ - 6| workerValue: [async ({}, use) => { - 7| log("workerValue setup"); - ❯ test.js:22:4 + 6| workerValue: [ + 7| async ({}, use) => { + ❯ test.js:28:4 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1704,8 +1704,8 @@ describe('scoped fixtures type safety', () => { 5| export const extendedTest = (({ log, expectTypeOf: _expectTypeOf }) =>… | ^ 6| // @ts-expect-error - worker fixture cannot access file-scoped fixtu… - 7| workerValue: [async ({ fileValue }, use) => { - ❯ test.js:22:4 + 7| workerValue: [ + ❯ test.js:28:4 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1762,8 +1762,8 @@ describe('scoped fixtures type safety', () => { 5| export const extendedTest = (({ log, expectTypeOf: _expectTypeOf }) =>… | ^ 6| // @ts-expect-error - worker fixture cannot access test-scoped fixtu… - 7| workerValue: [async ({ testValue }, use) => { - ❯ test.js:22:4 + 7| workerValue: [ + ❯ test.js:28:4 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1848,13 +1848,13 @@ describe('scoped fixtures type safety', () => { FAIL basic.test.ts > should fail TypeError: Cannot read properties of undefined (reading 'name') - ❯ it.extend.fileValue.scope test.js:9:20 - 7| fileValue: [async ({ task }, use) => { - 8| log("fileValue setup"); - 9| await use(task.name); - | ^ - 10| log("fileValue teardown"); - 11| }, { scope: "file" }], + ❯ it.extend.fileValue.scope test.js:10:22 + 8| async ({ task }, use) => { + 9| log("fileValue setup"); + 10| await use(task.name); + | ^ + 11| log("fileValue teardown"); + 12| }, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ @@ -1897,13 +1897,13 @@ describe('scoped fixtures type safety', () => { FAIL basic.test.ts > should fail TypeError: Cannot read properties of undefined (reading 'name') - ❯ it.extend.workerValue.scope test.js:9:20 - 7| workerValue: [async ({ task }, use) => { - 8| log("workerValue setup"); - 9| await use(task.name); - | ^ - 10| log("workerValue teardown"); - 11| }, { scope: "worker" }], + ❯ it.extend.workerValue.scope test.js:10:22 + 8| async ({ task }, use) => { + 9| log("workerValue setup"); + 10| await use(task.name); + | ^ + 11| log("workerValue teardown"); + 12| }, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ -- 2.51.2 From 07ee71706ca68182b1e584706bfe8938677bead1 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 29 Sep 2026 13:51:05 +0200 Subject: [PATCH 13/37] test: fix failing win test (#11390) --- .../test/fs-module-cache-invalidation.test.ts | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/test/e2e/test/fs-module-cache-invalidation.test.ts b/test/e2e/test/fs-module-cache-invalidation.test.ts index d6696ad9d..609860479 100644 --- a/test/e2e/test/fs-module-cache-invalidation.test.ts +++ b/test/e2e/test/fs-module-cache-invalidation.test.ts @@ -8,8 +8,7 @@ function cacheTimestamps(cachePath: string) { } // The import check must not report a false miss, or the cache is rewritten on -// every run: virtual modules resolve to ids that are not files, and an id may -// look like an absolute path without being one. +// every run: virtual modules resolve to ids that are not files. test('an unchanged module graph is served from the cache', async () => { const cold = await runInlineTests({ 'vitest.config.js': /* js */ ` @@ -19,11 +18,9 @@ test('an unchanged module graph is served from the cache', async () => { name: 'answer', resolveId(id) { if (id === 'virtual:answer') return '\\0virtual:answer' - if (id === 'virtual:base' || id === '/virtual/base') return '/virtual/base' }, load(id) { if (id === '\\0virtual:answer') return 'export const answer = 42' - if (id === '/virtual/base') return 'export const base = 0' }, }, ], @@ -35,9 +32,8 @@ test('an unchanged module graph is served from the cache', async () => { `, 'src/a.ts': /* ts */ ` import { answer } from 'virtual:answer' - import { base } from 'virtual:base' import { value } from './b' - export const sum = answer + base + value + export const sum = answer + value `, 'src/b.ts': `export const value = 1`, 'src/a.test.ts': /* ts */ ` @@ -74,6 +70,71 @@ test('an unchanged module graph is served from the cache', async () => { expect(cacheTimestamps(cachePath)).toEqual(timestamps) }) +// An id can look like an absolute path without being a file, so the url does +// not give it back. Windows cannot run such a module: its url has no drive +// letter, which is not a valid file url there. +test.skipIf(process.platform === 'win32')( + 'an import with an id that its url does not give back is served from the cache', + async () => { + const cold = await runInlineTests({ + 'vitest.config.js': /* js */ ` + export default { + plugins: [ + { + name: 'base', + resolveId(id) { + if (id === 'virtual:base' || id === '/virtual/base') return '/virtual/base' + }, + load(id) { + if (id === '/virtual/base') return 'export const base = 42' + }, + }, + ], + test: { + fsModuleCache: true, + fsModuleCachePath: './node_modules/.vitest-fs-cache', + }, + } + `, + 'src/a.ts': /* ts */ ` + import { base } from 'virtual:base' + export const sum = base + 1 + `, + 'src/a.test.ts': /* ts */ ` + import { expect, it } from 'vitest' + import { sum } from './a' + it('reads it', () => { + expect(sum).toBe(43) + }) + `, + }) + expect(cold.stderr).toBe('') + expect(cold.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + await cold.ctx?.close() + + const cachePath = join(cold.root, 'node_modules/.vitest-fs-cache') + const timestamps = cacheTimestamps(cachePath) + expect(timestamps.length).toBeGreaterThan(1) + + const warm = await runVitest({ root: cold.root }) + expect(warm.stderr).toBe('') + expect(warm.errorTree()).toMatchInlineSnapshot(` + { + "src/a.test.ts": { + "reads it": "passed", + }, + } + `) + expect(cacheTimestamps(cachePath)).toEqual(timestamps) + }, +) + // A cached transform embeds the resolved URLs of its imports, so it has to be // dropped when those imports no longer resolve to the same modules, even if // the importer's own source did not change (a branch switch renames a file). -- 2.51.2 From 50312ebb4eca6a98f6d0b2b61d5d9d38cbbabcef Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 29 Sep 2026 15:46:18 +0200 Subject: [PATCH 14/37] fix(pool): preserve unique pool ids when `groupOrder` is set (#11392) --- packages/vitest/src/node/pools/pool.ts | 4 +- test/e2e/test/group-order.test.ts | 79 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index 13cc822e5..dd14bd48f 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -80,6 +80,8 @@ export class Pool { let isMemoryLimitReached = false const runner = this.getPoolRunner(task, method) + // `setMaxWorkers` can replace the map for the next group before this id is freed + const workerIds = this.workerIds const poolId = runner.poolId ?? this.getConcurrencyId() runner.poolId = poolId @@ -195,7 +197,7 @@ export class Pool { ) } - this.freeWorkerId(poolId) + workerIds.set(poolId, true) } // This is mostly to avoid zombie workers when/if Vitest internals run into errors diff --git a/test/e2e/test/group-order.test.ts b/test/e2e/test/group-order.test.ts index 4cb3d1a0d..158dff2b4 100644 --- a/test/e2e/test/group-order.test.ts +++ b/test/e2e/test/group-order.test.ts @@ -58,3 +58,82 @@ test('tests run according to the group order', async () => { ✓ |1| example.1.test.ts > 1