diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml
index c796bb1221f0..22e166521265 100644
--- a/.github/actions/setup-playwright/action.yml
+++ b/.github/actions/setup-playwright/action.yml
@@ -18,10 +18,6 @@ runs:
"
)" >> $GITHUB_OUTPUT
- - name: Print versions
- shell: bash
- run: echo "${{ toJson(steps.resolve-package-versions.outputs) }}"
-
- name: Check resolved package versions
shell: bash
if: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dac3cc0d6004..6e5e126babc8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,6 +27,8 @@ jobs:
name: 'Lint: node-latest, ubuntu-latest'
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
@@ -66,6 +68,8 @@ jobs:
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- name: Get changed files
id: changed-files
@@ -98,6 +102,8 @@ jobs:
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
with:
@@ -160,6 +166,8 @@ jobs:
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
with:
@@ -196,6 +204,8 @@ jobs:
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
with:
@@ -229,6 +239,8 @@ jobs:
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
with:
@@ -278,6 +290,8 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: ./.github/actions/setup-and-cache
@@ -313,5 +327,7 @@ jobs:
- name: Link report viewer
run: |
- echo "::notice title=Vitest HTML report::View HTML report: https://viewer.vitest.dev/?url=${{ steps.upload-report.outputs.artifact-url }}"
- echo "[View HTML report](https://viewer.vitest.dev/?url=${{ steps.upload-report.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY
+ echo "::notice title=Vitest HTML report::View HTML report: https://viewer.vitest.dev/?url=${STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL}"
+ echo "[View HTML report](https://viewer.vitest.dev/?url=${STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL})" >> $GITHUB_STEP_SUMMARY
+ env:
+ STEPS_UPLOAD_REPORT_OUTPUTS_ARTIFACT_URL: ${{ steps.upload-report.outputs.artifact-url }}
diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml
index e953b2e8a0fe..5c4653374c42 100644
--- a/.github/workflows/cr.yml
+++ b/.github/workflows/cr.yml
@@ -22,6 +22,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
+ persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
diff --git a/.github/workflows/ecosystem-ci-trigger.yml b/.github/workflows/ecosystem-ci-trigger.yml
index 712cc473d601..9da4e578e398 100644
--- a/.github/workflows/ecosystem-ci-trigger.yml
+++ b/.github/workflows/ecosystem-ci-trigger.yml
@@ -4,9 +4,16 @@ on:
issue_comment:
types: [created]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.issue.number }}
+ cancel-in-progress: true
+
+permissions: {}
+
jobs:
trigger:
runs-on: ubuntu-latest
+ name: Run Ecosystem CI Tests
if: github.repository == 'vitest-dev/vitest' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/ecosystem-ci run')
permissions:
issues: write # to add / delete reactions, post comments
@@ -66,11 +73,14 @@ jobs:
repo: pr.head.repo.full_name
}
- id: generate-token
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
+ uses: actions/create-github-app-token@v3
with:
- app_id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }}
- installation_retrieval_payload: '${{ github.repository_owner }}/vitest-ecosystem-ci'
- private_key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }}
+ app-id: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_ID }}
+ private-key: ${{ secrets.ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY }}
+ repositories: |
+ vitest
+ vitest-ecosystem-ci
+ permission-actions: write
- uses: actions/github-script@v8
id: trigger
env:
diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml
index 48357e33e9db..14db69c79afe 100644
--- a/.github/workflows/issue-close-require.yml
+++ b/.github/workflows/issue-close-require.yml
@@ -5,9 +5,12 @@ on:
- cron: '0 0 * * *'
workflow_dispatch:
+permissions: {}
+
jobs:
close-issues:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-slim
+ name: Close Marked Issues
permissions:
issues: write # for actions-cool/issues-helper to update issues
steps:
@@ -27,7 +30,8 @@ jobs:
inactive-day: 3
close-prs:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-slim
+ name: Close Marked PRs
permissions:
issues: read # to query PRs by label via the issues API
pull-requests: write # to close pull requests
diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml
index 18c9d2cffaf6..60cf0483fc8f 100644
--- a/.github/workflows/issue-labeled.yml
+++ b/.github/workflows/issue-labeled.yml
@@ -3,20 +3,27 @@ name: Issue Labeled
on:
issues:
types: [labeled]
+ # zizmor: ignore[dangerous-triggers]
+ # We don't use any information from the PR content itself except the login of the user.
+ # The login is used only in the GitHub comment, not passed down as untrusted code.
pull_request_target:
types: [labeled]
-# for actions-cool/issues-helper to update issues
-permissions:
- issues: write
- pull-requests: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
+ cancel-in-progress: true
jobs:
- reply-labeled:
- runs-on: ubuntu-latest
+ reproduction-reply-labeled:
+ runs-on: ubuntu-slim
+ if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'needs reproduction'
+ name: Minimal Reproduction Label
+ permissions:
+ issues: write # adding a label
steps:
- name: needs reproduction
- if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'needs reproduction'
uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3.7.6
with:
actions: create-comment
@@ -24,8 +31,15 @@ jobs:
issue-number: ${{ github.event.issue.number }}
body: |
Hello @${{ github.event.issue.user.login }}. Please provide a [minimal reproduction](https://stackoverflow.com/help/minimal-reproducible-example) using a GitHub repository or [StackBlitz](https://vitest.new) (you can also use [examples](https://github.com/vitest-dev/vitest/tree/main/examples)). Issues marked with `needs reproduction` will be closed if they have no activity within 3 days.
- - name: maybe automated (issues)
- if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'maybe automated' && github.event_name == 'issues'
+
+ issue-clanker-comment:
+ runs-on: ubuntu-slim
+ if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'maybe automated' && github.event_name == 'issues'
+ name: Comment on Bot Issue
+ permissions:
+ issues: write # sending a comment
+ steps:
+ - name: maybe automated
uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3.7.6
with:
actions: create-comment
@@ -42,8 +56,15 @@ jobs:
If you believe this was flagged by mistake, leave a comment.
*These measures help us reduce maintenance burden and keep the team's work efficient. See our [AI contributions policy](https://github.com/vitest-dev/vitest/blob/main/CONTRIBUTING.md#ai-contributions) for more context.*
- - name: maybe automated (pr)
- if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'maybe automated' && github.event_name == 'pull_request_target'
+
+ issue-pr-comment:
+ runs-on: ubuntu-slim
+ if: github.repository == 'vitest-dev/vitest' && github.event.label.name == 'maybe automated' && github.event_name == 'pull_request_target'
+ name: Comment on Bot PR
+ permissions:
+ pull-requests: write # sending a comment
+ steps:
+ - name: maybe automated
uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3.7.6
with:
actions: create-comment
diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml
index 55a821318163..117a63df9f03 100644
--- a/.github/workflows/lock-closed-issues.yml
+++ b/.github/workflows/lock-closed-issues.yml
@@ -4,13 +4,15 @@ on:
schedule:
- cron: '0 0 * * *'
-permissions:
- issues: write
+permissions: {}
jobs:
action:
if: github.repository == 'vitest-dev/vitest'
runs-on: ubuntu-latest
+ name: Lock Closed Issues
+ permissions:
+ issues: write # to lock issue
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
diff --git a/.github/workflows/pr-labeled-automated.yml b/.github/workflows/pr-labeled-automated.yml
index 50ff5b429434..a19232c1bd75 100644
--- a/.github/workflows/pr-labeled-automated.yml
+++ b/.github/workflows/pr-labeled-automated.yml
@@ -1,22 +1,46 @@
name: Label Automated PR
on:
+ # zizmor: ignore[dangerous-triggers]
+ # Information from the PR is used only inside builtin `contains` function, it's not passed down as untrusted code.
pull_request_target:
types: [opened, edited]
-permissions:
- issues: write
- pull-requests: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
if: github.repository == 'vitest-dev/vitest' && contains(github.event.pull_request.body, '')
+ name: Automatic Clanker Alert
+ permissions:
+ pull-requests: write # comment and label on PRs
steps:
- - name: maybe automated
+ - name: maybe automated (label)
uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3.7.6
with:
actions: add-labels
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
labels: maybe automated
+ - name: maybe automated (pr)
+ uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3.7.6
+ with:
+ actions: create-comment
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ github.event.pull_request.number }}
+ body: |
+ Hello @${{ github.event.pull_request.user.login }}. Your PR has been labeled `maybe automated` because it appears to have been fully generated by AI with no human involvement. It will be **closed automatically in 3 days** unless a real person responds.
+
+ If you're a real person behind this contribution, please:
+ - Confirm you've personally reviewed and stand behind its content
+ - Make sure it follows our [contribution guidelines](https://github.com/vitest-dev/vitest/blob/main/CONTRIBUTING.md) and uses the correct [GitHub template](https://github.com/vitest-dev/vitest/blob/main/.github/PULL_REQUEST_TEMPLATE.md)
+ - Disclose any AI tools you used (e.g. Claude, Copilot, Codex)
+
+ If you believe this was flagged by mistake, leave a comment.
+
+ *These measures help us reduce maintenance burden and keep the team's work efficient. See our [AI contributions policy](https://github.com/vitest-dev/vitest/blob/main/CONTRIBUTING.md#ai-contributions) for more context.*
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ceab37963b54..00affa8f3c6b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,22 +5,26 @@ on:
tags:
- 'v*'
-permissions:
- contents: write
- id-token: write
+permissions: {}
env:
VITE_TEST_WATCHER_DEBUG: 'false'
jobs:
publish:
+ # only run on main, don't trigger in forks
if: github.repository == 'vitest-dev/vitest'
+ name: Publish Vitest
runs-on: ubuntu-latest
+ permissions:
+ contents: write # trusted publishing and changelog requirement
+ id-token: write # trusted publishing requirement
environment: Release
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
+ persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -30,7 +34,8 @@ jobs:
with:
node-version: 20
registry-url: https://registry.npmjs.org/
- cache: pnpm
+ # disable cache to avoid cache poisoning
+ package-manager-cache: false
- name: Install
run: pnpm install --frozen-lockfile --prefer-offline
@@ -41,7 +46,7 @@ jobs:
run: pnpm build
- name: Publish to npm
- run: npm i -g npm@^11.5.2 && pnpm run publish-ci "${{ github.ref_name }}"
+ run: npm i -g npm@^11.5.2 && pnpm run publish-ci "${GITHUB_REF_NAME}"
- name: Generate Changelog
run: npx changelogithub
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
new file mode 100644
index 000000000000..436ccb28c270
--- /dev/null
+++ b/.github/workflows/zizmor.yml
@@ -0,0 +1,32 @@
+name: Zizmor
+
+on:
+ workflow_dispatch:
+ pull_request:
+ push:
+ branches:
+ - main
+ paths:
+ - '.github/workflows/**'
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+ cancel-in-progress: ${{ github.ref_name != 'main' }}
+
+jobs:
+ zizmor:
+ name: Run zizmor
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Run zizmor 🌈
+ uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+ with:
+ persona: pedantic
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
new file mode 100644
index 000000000000..339387ce9977
--- /dev/null
+++ b/.github/zizmor.yml
@@ -0,0 +1,13 @@
+rules:
+ unpinned-uses:
+ config:
+ policies:
+ actions/*: ref-pin
+ github/*: ref-pin
+ concurrency-limits:
+ ignore:
+ # publish workflow doesn't run concurrently and requires a manual approval
+ - publish.yml
+ # the workflow runs on cron schedule
+ - lock-closed-issues.yml
+ - issue-close-require.yml
diff --git a/docs/api/advanced/vitest.md b/docs/api/advanced/vitest.md
index 7ff17b48ef6b..37e335f28ecd 100644
--- a/docs/api/advanced/vitest.md
+++ b/docs/api/advanced/vitest.md
@@ -8,7 +8,7 @@ title: Vitest API
Vitest instance requires the current test mode. It can be either:
- `test` when running runtime tests
-- `benchmark` when running benchmarks experimental
+- `benchmark` when running benchmarks
::: details New in Vitest 4
Vitest 4 added several new APIs (they are marked with a "4.0.0+" badge) and removed deprecated APIs:
@@ -31,7 +31,7 @@ Vitest 4 added several new APIs (they are marked with a "4.0.0+" badge) and remo
Test mode will only call functions inside `test` or `it`, and throws an error when `bench` is encountered. This mode uses `include` and `exclude` options in the config to find test files.
-### benchmark experimental
+### benchmark {#benchmark}
Benchmark mode calls `bench` functions and throws an error, when it encounters `test` or `it`. This mode uses `benchmark.include` and `benchmark.exclude` options in the config to find benchmark files.
@@ -47,7 +47,7 @@ This is Vitest config, it doesn't extend _Vite_ config. It only has resolved val
This is a global [`ViteDevServer`](https://vite.dev/guide/api-javascript#vitedevserver).
-## state experimental
+## state {#state}
::: warning
Public `state` is an experimental API (except `vitest.state.getReportedEntity`). Breaking changes might not follow SemVer, please pin Vitest's version when using it.
diff --git a/docs/api/browser/assertions.md b/docs/api/browser/assertions.md
index c1c0cd078d3c..7c2d905da4e8 100644
--- a/docs/api/browser/assertions.md
+++ b/docs/api/browser/assertions.md
@@ -1068,7 +1068,7 @@ await expect.element(queryByTestId('prev')).not.toHaveSelection()
await expect.element(queryByTestId('next')).toHaveSelection('ne')
```
-## toMatchScreenshot experimental {#tomatchscreenshot}
+## toMatchScreenshot {#tomatchscreenshot}
```ts
function toMatchScreenshot(
diff --git a/docs/api/vi.md b/docs/api/vi.md
index 1a0d99c58898..1f040959df01 100644
--- a/docs/api/vi.md
+++ b/docs/api/vi.md
@@ -1051,7 +1051,7 @@ vi.useRealTimers()
### vi.useFakeTimers
```ts
-function useFakeTimers(config?: FakeTimerInstallOpts): Vitest
+function useFakeTimers(config?: FakeTimersConfig): Vitest
```
To enable mocking timers, you need to call this method. It will wrap all further calls to timers (such as `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`, `setImmediate`, `clearImmediate`, and `Date`) until [`vi.useRealTimers()`](#vi-userealtimers) is called.
@@ -1065,6 +1065,16 @@ The implementation is based internally on [`@sinonjs/fake-timers`](https://githu
But you can enable it by specifying the option in `toFake` argument: `vi.useFakeTimers({ toFake: ['nextTick', 'queueMicrotask'] })`.
:::
+You can use `toFake` to specify which timers to mock, or `toNotFake` to specify which timers to keep native. Note that `toFake` and `toNotFake` cannot be specified together.
+
+```ts
+// only mock setTimeout and clearTimeout
+vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+
+// mock all timers except setInterval
+vi.useFakeTimers({ toNotFake: ['setInterval'] })
+```
+
### vi.setTimerTickMode 4.1.0 {#vi-settimertickmode}
- **Type:** `(mode: 'manual' | 'nextTimerAsync') => Vitest | (mode: 'interval', interval?: number) => Vitest`
diff --git a/docs/config/browser/webdriverio.md b/docs/config/browser/webdriverio.md
index 5b1c8e0b56f3..329010cb4ab4 100644
--- a/docs/config/browser/webdriverio.md
+++ b/docs/config/browser/webdriverio.md
@@ -60,5 +60,23 @@ You can find most available options in the [WebdriverIO documentation](https://w
::: tip
Most useful options are located on `capabilities` object. WebdriverIO allows nested capabilities, but Vitest will ignore those options because we rely on a different mechanism to spawn several browsers.
-Note that Vitest will ignore `capabilities.browserName` — use [`test.browser.instances.browser`](/config/browser/instances#browser) instead.
+Note that Vitest will ignore `capabilities.browserName`; use [`test.browser.instances.browser`](/config/browser/instances#browser) instead.
:::
+
+## Headful Chrome in CI
+
+Vitest enables [`browser.headless`](/config/browser/headless) automatically in CI.
+If you explicitly set `headless: false` for Chrome on a Linux CI runner, Chrome
+still needs a display server. Without one, WebDriverIO or ChromeDriver can fail
+with a misleading error such as `session not created: probably user data
+directory is already in use`.
+
+Run the test command through `xvfb-run` when you need headful Chrome in GitHub
+Actions or another Linux CI environment:
+
+```bash
+xvfb-run npm test
+```
+
+Alternatively, keep `browser.headless` enabled in CI and use headful mode only
+for local debugging.
diff --git a/docs/config/faketimers.md b/docs/config/faketimers.md
index 68b43e1c2394..2336bc3c9dd1 100644
--- a/docs/config/faketimers.md
+++ b/docs/config/faketimers.md
@@ -5,7 +5,7 @@ outline: deep
# fakeTimers
-- **Type:** `FakeTimerInstallOpts`
+- **Type:** `FakeTimerConfig`
Options that Vitest will pass down to [`@sinon/fake-timers`](https://npmx.dev/package/@sinonjs/fake-timers) when using [`vi.useFakeTimers()`](/api/vi#vi-usefaketimers).
@@ -21,12 +21,23 @@ Installs fake timers with the specified Unix epoch.
- **Type:** `('setTimeout' | 'clearTimeout' | 'setImmediate' | 'clearImmediate' | 'setInterval' | 'clearInterval' | 'Date' | 'nextTick' | 'hrtime' | 'requestAnimationFrame' | 'cancelAnimationFrame' | 'requestIdleCallback' | 'cancelIdleCallback' | 'performance' | 'queueMicrotask')[]`
- **Default:** everything available globally except `nextTick` and `queueMicrotask`
-An array with names of global methods and APIs to fake.
-
-To only mock `setTimeout()` and `nextTick()`, specify this property as `['setTimeout', 'nextTick']`.
+An array with names of global methods and APIs to fake. For example, to only mock `setTimeout()` and `nextTick()`, specify this property as `['setTimeout', 'nextTick']`.
Mocking `nextTick` is not supported when running Vitest inside `node:child_process` by using `--pool=forks`. NodeJS uses `process.nextTick` internally in `node:child_process` and hangs when it is mocked. Mocking `nextTick` is supported when running Vitest with `--pool=threads`.
+## fakeTimers.toNotFake
+
+- **Type:** `('setTimeout' | 'clearTimeout' | 'setImmediate' | 'clearImmediate' | 'setInterval' | 'clearInterval' | 'Date' | 'nextTick' | 'hrtime' | 'requestAnimationFrame' | 'cancelAnimationFrame' | 'requestIdleCallback' | 'cancelIdleCallback' | 'performance' | 'queueMicrotask')[]`
+- **Default:** `[]`
+
+An array with names of global methods and APIs to keep native. All other available timers will be mocked. For example, to keep `setInterval()` native and mock all other timers, specify this property as `['setInterval']`.
+
+Mocking `nextTick` is not supported when running Vitest inside `node:child_process` by using `--pool=forks`. When running with `--pool=forks`, Vitest automatically adds `nextTick` to the `toNotFake` array.
+
+::: warning
+Using both `toFake` and `toNotFake` together is not supported.
+:::
+
## fakeTimers.loopLimit
- **Type:** `number`
diff --git a/docs/guide/browser/aria-snapshots.md b/docs/guide/browser/aria-snapshots.md
index d7d8ae76fdf8..572944183256 100644
--- a/docs/guide/browser/aria-snapshots.md
+++ b/docs/guide/browser/aria-snapshots.md
@@ -3,7 +3,7 @@ title: ARIA Snapshots | Guide
outline: deep
---
-# ARIA Snapshots experimental 4.1.4
+# ARIA Snapshots 4.1.4 {#aria-snapshots}
ARIA snapshots let you test the accessibility structure of your pages. Instead of asserting against raw HTML or visual output, you assert against the accessibility tree — the same structure that screen readers and other assistive technologies use.
diff --git a/docs/guide/features.md b/docs/guide/features.md
index 31bc5373ab92..3c73a69cceab 100644
--- a/docs/guide/features.md
+++ b/docs/guide/features.md
@@ -194,7 +194,7 @@ if (import.meta.vitest) {
Learn more at [In-source testing](/guide/in-source).
-## Benchmarking Experimental {#benchmarking}
+## Benchmarking {#benchmarking}
You can run benchmark tests with [`bench`](/api/test#bench) function via [Tinybench](https://github.com/tinylibs/tinybench) to compare performance results.
@@ -221,7 +221,7 @@ describe('sort', () => {
-## Type Testing Experimental {#type-testing}
+## Type Testing {#type-testing}
You can [write tests](/guide/testing-types) to catch type regressions. Vitest comes with [`expect-type`](https://github.com/mmkal/expect-type) package to provide you with a similar and easy to understand API.
diff --git a/docs/guide/migration.md b/docs/guide/migration.md
index 45b19e634af6..93c10a080a48 100644
--- a/docs/guide/migration.md
+++ b/docs/guide/migration.md
@@ -712,7 +712,7 @@ export default defineConfig({
Otherwise your snapshots will have a lot of escaped `"` characters.
-### Custom Snapshot Matchers experimental 4.1.3
+### Custom Snapshot Matchers 4.1.3 {#custom-snapshot-matcher}
Jest imports snapshot composables from `jest-snapshot`. In Vitest, use `Snapshots` from `vitest` instead:
diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md
index e99faccd23c3..9db1f71fe701 100644
--- a/docs/guide/snapshot.md
+++ b/docs/guide/snapshot.md
@@ -124,7 +124,7 @@ test('button looks correct', async () => {
This captures screenshots and compares them against reference images to detect unintended visual changes. Learn more in the [Visual Regression Testing guide](/guide/browser/visual-regression-testing).
-## ARIA Snapshots experimental 4.1.4
+## ARIA Snapshots 4.1.4 {#aria-snapshots}
ARIA snapshots capture the accessibility tree of a DOM element and compare it against a stored template. Based on [Playwright's ARIA snapshots](https://playwright.dev/docs/aria-snapshots), they provide a semantic alternative to visual regression testing — asserting structure and meaning rather than pixels.
@@ -236,7 +236,7 @@ Pretty foo: Object {
}
```
-## Custom Snapshot Matchers experimental 4.1.3 {#custom-snapshot-matchers}
+## Custom Snapshot Matchers 4.1.3 {#custom-snapshot-matchers}
You can build custom snapshot matchers using the composable functions exposed on `Snapshots` from `vitest`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting).
@@ -335,7 +335,7 @@ declare module 'vitest' {
See [Extending Matchers](/guide/extending-matchers) for more on `expect.extend` and custom matcher conventions.
:::
-## Custom Snapshot Domain experimental 4.1.4 {#custom-snapshot-domain}
+## Custom Snapshot Domain 4.1.4 {#custom-snapshot-domain}
Custom serializers control how values are _rendered_ into snapshot strings, but comparison is still string equality. A **domain snapshot adapter** goes further: it owns the entire comparison pipeline for a custom matcher, including how to capture a value, render it, parse a stored snapshot, and match them semantically.
diff --git a/package.json b/package.json
index ad481b8bdd11..b56774a0bc5d 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"type": "module",
"version": "5.0.0-beta.2",
"private": true,
- "packageManager": "pnpm@10.31.0",
+ "packageManager": "pnpm@11.1.2",
"description": "Next generation testing framework powered by Vite",
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts
index c2716caa2123..5a037085e7dc 100644
--- a/packages/expect/src/types.ts
+++ b/packages/expect/src/types.ts
@@ -152,7 +152,7 @@ interface CustomMatcher {
* expect('foo').toBeOneOf([expect.any(String)])
* expect({ a: 1 }).toEqual({ a: expect.toBeOneOf(['1', '2', '3']) })
*/
- toBeOneOf: (sample: Array | Set) => any
+ toBeOneOf: (sample: ReadonlyArray | ReadonlySet) => any
}
export interface AsymmetricMatchersContaining extends CustomMatcher {
diff --git a/packages/ui/client/composables/client/state.ts b/packages/ui/client/composables/client/state.ts
index 971f9f17fbfe..106b54f841e6 100644
--- a/packages/ui/client/composables/client/state.ts
+++ b/packages/ui/client/composables/client/state.ts
@@ -25,13 +25,8 @@ export const tagsDefinitions = computed(() => {
export class StateManager {
filesMap: Map = new Map()
- pathsSet: Set = new Set()
idMap: Map = new Map()
- getPaths(): string[] {
- return Array.from(this.pathsSet)
- }
-
/**
* Return files that were running or collected.
*/
@@ -55,12 +50,6 @@ export class StateManager {
.map(i => i.filepath)
}
- collectPaths(paths: string[] = []): void {
- paths.forEach((path) => {
- this.pathsSet.add(path)
- })
- }
-
collectFiles(files: RunnerTestFile[] = []): void {
files.forEach((file) => {
const existing = this.filesMap.get(file.filepath) || []
diff --git a/packages/ui/client/composables/client/static.ts b/packages/ui/client/composables/client/static.ts
index 06000e9bb001..67ab5f592aae 100644
--- a/packages/ui/client/composables/client/static.ts
+++ b/packages/ui/client/composables/client/static.ts
@@ -1,116 +1,99 @@
-import type { BirpcReturn } from 'birpc'
import type {
ModuleGraphData,
RunnerTestFile,
SerializedRootConfig,
- WebSocketEvents,
- WebSocketHandlers,
} from 'vitest'
-import type { VitestClient } from './ws'
+import type { VitestClient, VitestClientRpc } from './ws'
import { decompressSync, strFromU8 } from 'fflate'
import { parse } from 'flatted'
import { reactive } from 'vue'
import { StateManager } from './state'
-interface HTMLReportMetadata {
- paths: string[]
+export interface HTMLReportMetadata {
files: RunnerTestFile[]
config: SerializedRootConfig
moduleGraph: Record>
unhandledErrors: unknown[]
- // filename -> source
- sources: Record
+ testModules: {
+ projectName: string
+ moduleId: string
+ relativeModuleId: string
+ }[]
+ sourceCode: {
+ codeTable: string[]
+ testModules: { [projectName: string]: { [relativeModuleId: string]: number } }
+ }
}
-const noop: any = () => {}
-const asyncNoop: any = () => Promise.resolve()
-
-export function createStaticClient(): VitestClient {
- const ctx = reactive({
- state: new StateManager(),
- waitForConnection,
- reconnect,
- ws: new EventTarget(),
- }) as VitestClient
-
- ctx.state.filesMap = reactive(ctx.state.filesMap)
- ctx.state.idMap = reactive(ctx.state.idMap)
-
- let metadata!: HTMLReportMetadata
+function deserializeReportMetadata(metadata: HTMLReportMetadata) {
+ const sourceCodes: { [moduleId: string]: string } = {}
+ for (const testModule of metadata.testModules) {
+ const codeIndex = metadata.sourceCode.testModules[testModule.projectName]?.[testModule.relativeModuleId]
+ if (codeIndex != null) {
+ sourceCodes[testModule.moduleId] = metadata.sourceCode.codeTable[codeIndex]
+ }
+ }
- const rpc = {
- getFiles: () => {
+ const rpc: VitestClientRpc = {
+ getFiles: async () => {
return metadata.files
},
- getPaths: () => {
- return metadata.paths
- },
- getConfig: () => {
+ getConfig: async () => {
return metadata.config
},
getModuleGraph: async (projectName, id) => {
return metadata.moduleGraph[projectName]?.[id]
},
- getUnhandledErrors: () => {
+ getUnhandledErrors: async () => {
return metadata.unhandledErrors
},
- getExternalResult: asyncNoop,
- getTransformResult: asyncNoop,
- onDone: noop,
- writeFile: asyncNoop,
- rerun: asyncNoop,
- rerunTask: asyncNoop,
- updateSnapshot: asyncNoop,
- resolveSnapshotPath: asyncNoop,
- snapshotSaved: asyncNoop,
- onAfterSuiteRun: asyncNoop,
- onCancel: asyncNoop,
- getCountOfFailedTests: () => 0,
- sendLog: asyncNoop,
- resolveSnapshotRawPath: asyncNoop,
- readSnapshotFile: asyncNoop,
- saveSnapshotFile: asyncNoop,
- readTestFile: async (id: string) => {
- return metadata.sources[id]
+ readTestFile: async (id) => {
+ return sourceCodes[id]
},
- removeSnapshotFile: asyncNoop,
- onUnhandledError: noop,
- saveTestFile: asyncNoop,
- getProvidedContext: () => ({}),
- getTestFiles: asyncNoop,
- } as Omit
-
- ctx.rpc = rpc as any as BirpcReturn
+ getPaths: async () => [],
+ getResolvedProjectLabels: async () => [],
+ getExternalResult: async () => undefined,
+ getTransformResult: async () => undefined,
+ rerun: async () => {},
+ rerunTask: async () => {},
+ updateSnapshot: async () => {},
+ saveTestFile: async () => {},
+ getTestFiles: async () => [],
+ }
+ return rpc
+}
- const openPromise = Promise.resolve()
+export function createStaticClient(): VitestClient {
+ const ctx = reactive({
+ ws: new EventTarget() as WebSocket,
+ state: new StateManager(),
+ rpc: undefined!,
+ reconnect: () => registerMetadata(),
+ waitForConnection: async () => {},
+ })
- function reconnect() {
- registerMetadata()
- }
+ ctx.state.filesMap = reactive(ctx.state.filesMap)
+ ctx.state.idMap = reactive(ctx.state.idMap)
async function registerMetadata() {
const res = await fetch(window.METADATA_PATH!)
const content = new Uint8Array(await res.arrayBuffer())
-
+ let metadata: HTMLReportMetadata
// Check for gzip magic numbers (0x1f 0x8b) to determine if content is compressed.
// This handles cases where a static server incorrectly sets Content-Encoding: gzip
// for .gz files, causing the browser to auto-decompress before we process the raw gzip data.
if (content.length >= 2 && content[0] === 0x1F && content[1] === 0x8B) {
const decompressed = strFromU8(decompressSync(content))
- metadata = parse(decompressed) as HTMLReportMetadata
+ metadata = parse(decompressed)
}
else {
- metadata = parse(strFromU8(content)) as HTMLReportMetadata
+ metadata = parse(strFromU8(content))
}
- const event = new Event('open')
- ctx.ws.dispatchEvent(event)
+ ctx.rpc = deserializeReportMetadata(metadata)
+ ctx.ws.dispatchEvent(new Event('open'))
}
registerMetadata()
- function waitForConnection() {
- return openPromise
- }
-
return ctx
}
diff --git a/packages/ui/client/composables/client/ws.ts b/packages/ui/client/composables/client/ws.ts
index d7c231a25c1d..fa5266ba0e4b 100644
--- a/packages/ui/client/composables/client/ws.ts
+++ b/packages/ui/client/composables/client/ws.ts
@@ -1,4 +1,4 @@
-import type { BirpcOptions, BirpcReturn } from 'birpc'
+import type { BirpcOptions, PromisifyFn } from 'birpc'
import type { WebSocketEvents, WebSocketHandlers } from 'vitest'
import { createBirpc } from 'birpc'
import { parse, stringify } from 'flatted'
@@ -15,10 +15,15 @@ export interface VitestClientOptions {
WebSocketConstructor?: typeof WebSocket
}
+export type VitestClientRpc = {
+ [K in keyof WebSocketHandlers]: PromisifyFn
+}
+
export interface VitestClient {
ws: WebSocket
state: StateManager
- rpc: BirpcReturn
+ rpc: VitestClientRpc
+ // TODO: unused
waitForConnection: () => Promise
reconnect: () => Promise
}
@@ -35,12 +40,14 @@ export function createWsClient(url: string, options: VitestClientOptions = {}):
} = options
let tries = reconnectTries
- const ctx = reactive({
+ let openPromise: Promise
+ const ctx = reactive({
ws: new WebSocketConstructor(url),
state: new StateManager(),
- waitForConnection,
+ rpc: undefined!,
+ waitForConnection: () => openPromise,
reconnect,
- }, 'state') as VitestClient
+ }, 'state')
ctx.state.filesMap = reactive(ctx.state.filesMap, 'filesMap')
ctx.state.idMap = reactive(ctx.state.idMap, 'idMap')
@@ -59,10 +66,6 @@ export function createWsClient(url: string, options: VitestClientOptions = {}):
})
handlers.onSpecsCollected?.(specs, startTime)
},
- onPathsCollected(paths) {
- ctx.state.collectPaths(paths)
- handlers.onPathsCollected?.(paths)
- },
onCollected(files) {
ctx.state.collectFiles(files)
handlers.onCollected?.(files)
@@ -106,9 +109,7 @@ export function createWsClient(url: string, options: VitestClientOptions = {}):
birpcHandlers,
)
- let openPromise: Promise
-
- function reconnect(reset = false) {
+ async function reconnect(reset = false) {
if (reset) {
tries = reconnectTries
}
@@ -148,9 +149,5 @@ export function createWsClient(url: string, options: VitestClientOptions = {}):
registerWS()
- function waitForConnection() {
- return openPromise
- }
-
return ctx
}
diff --git a/packages/ui/node/reporter.ts b/packages/ui/node/reporter.ts
index 04b3aa596917..76bd5660c0e7 100644
--- a/packages/ui/node/reporter.ts
+++ b/packages/ui/node/reporter.ts
@@ -1,6 +1,7 @@
-import type { ModuleGraphData, RunnerTestFile, SerializedRootConfig } from 'vitest'
-import type { HTMLOptions, Reporter, Vitest } from 'vitest/node'
-import { existsSync, promises as fs } from 'node:fs'
+import type { SerializedError } from 'vitest'
+import type { HTMLOptions, Reporter, TestModule, Vitest } from 'vitest/node'
+import type { HTMLReportMetadata } from '../client/composables/client/static'
+import { existsSync, promises as fs, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { gzip, constants as zlibConstants } from 'node:zlib'
@@ -26,16 +27,6 @@ function getOutputFile(config: PotentialConfig | undefined) {
return config.outputFile.html
}
-interface HTMLReportData {
- paths: string[]
- files: RunnerTestFile[]
- config: SerializedRootConfig
- moduleGraph: Record>
- unhandledErrors: unknown[]
- // filename -> source
- sources: Record
-}
-
const distDir = resolve(fileURLToPath(import.meta.url), '../../dist')
export default class HTMLReporter implements Reporter {
@@ -64,45 +55,17 @@ export default class HTMLReporter implements Reporter {
await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true })
}
- async onTestRunEnd(): Promise {
- const result: HTMLReportData = {
- paths: this.ctx.state.getPaths(),
- files: this.ctx.state.getFiles(),
- config: this.ctx.serializedRootConfig,
- unhandledErrors: this.ctx.state.getUnhandledErrors(),
- moduleGraph: {},
- sources: {},
- }
- const promises: Promise[] = []
-
- promises.push(...result.files.map(async (file) => {
- const projectName = file.projectName || ''
- const resolvedConfig = this.ctx.getProjectByName(projectName).config
- const browser = resolvedConfig.browser.enabled
- result.moduleGraph[projectName] ??= {}
- result.moduleGraph[projectName][file.filepath] = await getModuleGraph(
- this.ctx,
- projectName,
- file.filepath,
- browser,
- )
- if (!result.sources[file.filepath]) {
- try {
- result.sources[file.filepath] = await fs.readFile(file.filepath, {
- encoding: 'utf-8',
- })
- }
- catch {
- // just ignore
- }
- }
- }))
-
- await Promise.all(promises)
- await this.writeReport(stringify(result))
- }
+ async onTestRunEnd(
+ testModules: ReadonlyArray,
+ unhandledErrors: ReadonlyArray,
+ ): Promise {
+ const result = await serializeReportMetadata(
+ this.ctx,
+ testModules,
+ unhandledErrors,
+ )
+ const report = stringify(result)
- async writeReport(report: string): Promise {
const metaFile = resolve(this.reporterDir, 'html.meta.json.gz')
const promiseGzip = promisify(gzip)
@@ -169,3 +132,81 @@ export default class HTMLReporter implements Reporter {
}
}
}
+
+async function serializeReportMetadata(
+ ctx: Vitest,
+ testModules: ReadonlyArray,
+ unhandledErrors: ReadonlyArray,
+) {
+ const result: HTMLReportMetadata = {
+ files: [],
+ config: ctx.serializedRootConfig,
+ unhandledErrors: [...unhandledErrors],
+ moduleGraph: {},
+ testModules: [],
+ sourceCode: {
+ codeTable: [],
+ testModules: {},
+ },
+ }
+
+ // dedupe based on project relative paths since
+ // they can have different absolute paths for different test runs
+ // when merging with platform blob labels and shards.
+ // Source code is stored in a separate table so the same file included
+ // in multiple projects can share the content while keeping distinct
+ // project-relative test module entries.
+ const testModuleCodes = result.sourceCode.testModules
+ const codeIndexes = new Map()
+ function getCodeIndex(code: string) {
+ const existing = codeIndexes.get(code)
+ if (existing != null) {
+ return existing
+ }
+ const index = result.sourceCode.codeTable.length
+ codeIndexes.set(code, index)
+ result.sourceCode.codeTable.push(code)
+ return index
+ }
+
+ const promises: Promise[] = []
+
+ for (const testModule of testModules) {
+ result.files.push(testModule.task)
+
+ const project = testModule.project
+ const projectName = project.name
+ result.testModules.push({
+ projectName,
+ moduleId: testModule.moduleId,
+ relativeModuleId: testModule.relativeModuleId,
+ })
+
+ testModuleCodes[projectName] ??= {}
+ if (testModuleCodes[projectName][testModule.relativeModuleId] == null) {
+ try {
+ const code = readFileSync(
+ testModule.moduleId,
+ 'utf-8',
+ )
+ testModuleCodes[projectName][testModule.relativeModuleId] = getCodeIndex(code)
+ }
+ catch {}
+ }
+
+ // TODO: https://github.com/vitest-dev/vitest/issues/9763
+ promises.push((async () => {
+ result.moduleGraph[projectName] ??= {}
+ result.moduleGraph[projectName][testModule.moduleId] = await getModuleGraph(
+ ctx,
+ projectName,
+ testModule.moduleId,
+ project.config.browser.enabled,
+ )
+ })())
+ }
+
+ await Promise.all(promises)
+
+ return result
+}
diff --git a/packages/vitest/package.json b/packages/vitest/package.json
index fa33c24a71aa..9046ad698be3 100644
--- a/packages/vitest/package.json
+++ b/packages/vitest/package.json
@@ -192,7 +192,7 @@
"@edge-runtime/vm": "^5.0.0",
"@jridgewell/trace-mapping": "catalog:",
"@opentelemetry/api": "^1.9.0",
- "@sinonjs/fake-timers": "15.0.0",
+ "@sinonjs/fake-timers": "15.3.2",
"@types/estree": "catalog:",
"@types/istanbul-lib-coverage": "catalog:",
"@types/istanbul-reports": "catalog:",
@@ -200,7 +200,6 @@
"@types/node": "^24.12.0",
"@types/picomatch": "^4.0.2",
"@types/prompts": "^2.4.9",
- "@types/sinonjs__fake-timers": "^15.0.1",
"@vitest/expect": "workspace:*",
"@vitest/snapshot": "workspace:*",
"acorn": "8.11.3",
diff --git a/packages/vitest/src/defaults.ts b/packages/vitest/src/defaults.ts
index 77bf14ffe96b..3d29a547f8cc 100644
--- a/packages/vitest/src/defaults.ts
+++ b/packages/vitest/src/defaults.ts
@@ -88,7 +88,7 @@ export const configDefaults: Readonly<{
include: never[]
}
coverage: CoverageOptions
- fakeTimers: import('@sinonjs/fake-timers').FakeTimerInstallOpts
+ fakeTimers: import('@sinonjs/fake-timers').Config
maxConcurrency: number
dangerouslyIgnoreUnhandledErrors: boolean
typecheck: {
diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts
index ae60e349a2ea..fca90e896301 100644
--- a/packages/vitest/src/integrations/mock/timers.ts
+++ b/packages/vitest/src/integrations/mock/timers.ts
@@ -6,9 +6,10 @@
*/
import type {
- FakeTimerInstallOpts,
- FakeTimerWithContext,
- InstalledClock,
+ Clock,
+ FakeMethod,
+ Config as FakeTimersConfig,
+ FakeTimers as FakeTimersContext,
} from '@sinonjs/fake-timers'
import { withGlobal } from '@sinonjs/fake-timers'
import { isChildProcess } from '../../runtime/utils'
@@ -16,7 +17,7 @@ import { mockDate, RealDate, resetDate } from './date'
export class FakeTimers {
private _global: typeof globalThis
- private _clock!: InstalledClock
+ private _clock!: Clock
// | _fakingTime | _fakingDate |
// +-------------+-------------+
// | false | falsy | initial
@@ -25,8 +26,8 @@ export class FakeTimers {
// | true | truthy | unreachable
private _fakingTime: boolean
private _fakingDate: Date | null
- private _fakeTimers: FakeTimerWithContext
- private _userConfig?: FakeTimerInstallOpts
+ private _fakeTimers: FakeTimersContext
+ private _userConfig?: FakeTimersConfig
private _now = RealDate.now
constructor({
@@ -34,7 +35,7 @@ export class FakeTimers {
config,
}: {
global: typeof globalThis
- config: FakeTimerInstallOpts
+ config: FakeTimersConfig
}) {
this._userConfig = config
@@ -154,22 +155,28 @@ export class FakeTimers {
this._clock.uninstall()
}
- const toFake = Object.keys(this._fakeTimers.timers)
- // Do not mock timers internally used by node by default. It can still be mocked through userConfig.
- .filter(
- timer => timer !== 'nextTick' && timer !== 'queueMicrotask',
- ) as (keyof FakeTimerWithContext['timers'])[]
-
- if (this._userConfig?.toFake?.includes('nextTick') && isChildProcess()) {
+ let toFake = this._userConfig?.toFake
+ if (isChildProcess() && toFake?.includes('nextTick')) {
throw new Error(
'process.nextTick cannot be mocked inside child_process',
)
}
+ let toNotFake = this._userConfig?.toNotFake
+ if (toFake === undefined && toNotFake === undefined) {
+ // Do not mock timers internally used by node by default. It can still be mocked through userConfig.
+ toFake = (Object.keys(this._fakeTimers.timers) as FakeMethod[])
+ .filter(timer => timer !== 'nextTick' && timer !== 'queueMicrotask')
+ }
+ if (isChildProcess() && toNotFake && !toNotFake.includes('nextTick')) {
+ toNotFake = [...toNotFake, 'nextTick']
+ }
+
this._clock = this._fakeTimers.install({
now: fakeDate,
...this._userConfig,
- toFake: this._userConfig?.toFake || toFake,
+ ...(toFake && { toFake }),
+ ...(toNotFake && { toNotFake }),
ignoreMissingTimers: true,
})
@@ -228,7 +235,7 @@ export class FakeTimers {
}
}
- configure(config: FakeTimerInstallOpts): void {
+ configure(config: FakeTimersConfig): void {
this._userConfig = config
}
diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts
index f8d94f99ec2e..54f081de2909 100644
--- a/packages/vitest/src/integrations/vi.ts
+++ b/packages/vitest/src/integrations/vi.ts
@@ -1,4 +1,4 @@
-import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
+import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers'
import type {
MaybeMocked,
MaybeMockedDeep,
@@ -27,7 +27,7 @@ export interface VitestUtils {
/**
* This method wraps all further calls to timers until [`vi.useRealTimers()`](https://vitest.dev/api/vi#vi-userealtimers) is called.
*/
- useFakeTimers: (config?: FakeTimerInstallOpts) => VitestUtils
+ useFakeTimers: (config?: FakeTimersConfig) => VitestUtils
/**
* Restores mocked timers to their original implementations. All timers that were scheduled before will be discarded.
*/
@@ -496,7 +496,7 @@ function createVitest(): VitestUtils {
const _envBooleans = ['PROD', 'DEV', 'SSR']
const utils: VitestUtils = {
- useFakeTimers(config?: FakeTimerInstallOpts) {
+ useFakeTimers(config?: FakeTimersConfig) {
if (isChildProcess()) {
if (
config?.toFake?.includes('nextTick')
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index 0203846348a6..452d8661614a 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -1,4 +1,4 @@
-import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
+import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers'
import type { PrettyFormatOptions } from '@vitest/pretty-format'
import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner'
import type { SnapshotStateOptions } from '@vitest/snapshot'
@@ -617,7 +617,7 @@ export interface InlineConfig {
/**
* Options for @sinon/fake-timers
*/
- fakeTimers?: FakeTimerInstallOpts
+ fakeTimers?: FakeTimersConfig
/**
* Custom handler for console.log in tests.
diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts
index c1d6c0418f06..a8e2e298bc57 100644
--- a/packages/vitest/src/runtime/config.ts
+++ b/packages/vitest/src/runtime/config.ts
@@ -1,4 +1,4 @@
-import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
+import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers'
import type { PrettyFormatOptions } from '@vitest/pretty-format'
import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner'
import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot'
@@ -34,7 +34,7 @@ export interface SerializedConfig {
unstubGlobals: boolean
unstubEnvs: boolean
// TODO: make optional
- fakeTimers: FakeTimerInstallOpts
+ fakeTimers: FakeTimersConfig
maxConcurrency: number
defines: Record
expect: {
diff --git a/patches/@sinonjs__fake-timers@15.0.0.patch b/patches/@sinonjs__fake-timers@15.3.2.patch
similarity index 86%
rename from patches/@sinonjs__fake-timers@15.0.0.patch
rename to patches/@sinonjs__fake-timers@15.3.2.patch
index ec8ed6bd8217..779976520a36 100644
--- a/patches/@sinonjs__fake-timers@15.0.0.patch
+++ b/patches/@sinonjs__fake-timers@15.3.2.patch
@@ -1,5 +1,5 @@
diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js
-index a9bcfd1ca..942539085 100644
+index 7d4cfa1b6b8aebc8575e08bd432f8f8ca43d3c4f..09d12bec356aa1048f09771e915075fdecb065b9 100644
--- a/src/fake-timers-src.js
+++ b/src/fake-timers-src.js
@@ -2,14 +2,14 @@
@@ -11,16 +11,16 @@ index a9bcfd1ca..942539085 100644
try {
- timersModule = require("timers");
+ timersModule = __vitest_required__.timers;
- } catch (e) {
+ } catch {
// ignored
}
try {
- timersPromisesModule = require("timers/promises");
+ timersPromisesModule = __vitest_required__.timersPromises;
- } catch (e) {
+ } catch {
// ignored
}
-@@ -197,7 +197,7 @@ function withGlobal(_global) {
+@@ -510,7 +510,7 @@ function withGlobal(_global) {
isPresent.hrtime && typeof _global.process.hrtime.bigint === "function";
isPresent.nextTick =
_global.process && typeof _global.process.nextTick === "function";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 928275647122..8034554ba55c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -180,9 +180,9 @@ overrides:
vitest: workspace:*
patchedDependencies:
- '@sinonjs/fake-timers@15.0.0':
- hash: 8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2
- path: patches/@sinonjs__fake-timers@15.0.0.patch
+ '@sinonjs/fake-timers@15.3.2':
+ hash: 5513cab538aec68ba021f971d90c859c11658db640ba76d316c8df0d2915b0b0
+ path: patches/@sinonjs__fake-timers@15.3.2.patch
acorn@8.11.3:
hash: 62f89b815dbd769c8a4d5b19b1f6852f28922ecb581d876c8a8377d05c2483c4
path: patches/acorn@8.11.3.patch
@@ -1115,8 +1115,8 @@ importers:
specifier: ^1.9.0
version: 1.9.0
'@sinonjs/fake-timers':
- specifier: 15.0.0
- version: 15.0.0(patch_hash=8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2)
+ specifier: 15.3.2
+ version: 15.3.2(patch_hash=5513cab538aec68ba021f971d90c859c11658db640ba76d316c8df0d2915b0b0)
'@types/estree':
specifier: 'catalog:'
version: 1.0.8
@@ -1138,9 +1138,6 @@ importers:
'@types/prompts':
specifier: ^2.4.9
version: 2.4.9
- '@types/sinonjs__fake-timers':
- specifier: ^15.0.1
- version: 15.0.1
'@vitest/expect':
specifier: workspace:*
version: link:../expect
@@ -4841,11 +4838,8 @@ packages:
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
- '@sinonjs/fake-timers@15.0.0':
- resolution: {integrity: sha512-dlUB2oL+hDIYkIq/OWFBDhQAuU6kDey3eeMiYpVb7UXHhkMq/r1HloKXAbJwJZpYWkFWsydLjMqDpueMUEOjXQ==}
-
- '@sinonjs/fake-timers@15.1.1':
- resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==}
+ '@sinonjs/fake-timers@15.3.2':
+ resolution: {integrity: sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==}
'@sinonjs/samsam@9.0.3':
resolution: {integrity: sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==}
@@ -5206,9 +5200,6 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
- '@types/sinonjs__fake-timers@15.0.1':
- resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==}
-
'@types/sinonjs__fake-timers@8.1.5':
resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
@@ -13256,11 +13247,7 @@ snapshots:
dependencies:
type-detect: 4.0.8
- '@sinonjs/fake-timers@15.0.0(patch_hash=8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2)':
- dependencies:
- '@sinonjs/commons': 3.0.1
-
- '@sinonjs/fake-timers@15.1.1':
+ '@sinonjs/fake-timers@15.3.2(patch_hash=5513cab538aec68ba021f971d90c859c11658db640ba76d316c8df0d2915b0b0)':
dependencies:
'@sinonjs/commons': 3.0.1
@@ -13604,8 +13591,6 @@ snapshots:
'@types/resolve@1.20.2': {}
- '@types/sinonjs__fake-timers@15.0.1': {}
-
'@types/sinonjs__fake-timers@8.1.5': {}
'@types/statuses@2.0.6': {}
@@ -18701,7 +18686,7 @@ snapshots:
sinon@21.0.3:
dependencies:
'@sinonjs/commons': 3.0.1
- '@sinonjs/fake-timers': 15.1.1
+ '@sinonjs/fake-timers': 15.3.2(patch_hash=5513cab538aec68ba021f971d90c859c11658db640ba76d316c8df0d2915b0b0)
'@sinonjs/samsam': 9.0.3
diff: 8.0.3
supports-color: 7.2.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 557f3f7a69de..74eb8051cb69 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -39,7 +39,7 @@ overrides:
vite: $vite
vitest: workspace:*
patchedDependencies:
- '@sinonjs/fake-timers@15.0.0': patches/@sinonjs__fake-timers@15.0.0.patch
+ '@sinonjs/fake-timers@15.3.2': patches/@sinonjs__fake-timers@15.3.2.patch
acorn@8.11.3: patches/acorn@8.11.3.patch
cac@6.7.14: patches/cac@6.7.14.patch
istanbul-lib-instrument: patches/istanbul-lib-instrument.patch
@@ -100,17 +100,16 @@ catalog:
vue: ^3.5.29
ws: ^8.19.0
yauzl: ^3.2.0
-onlyBuiltDependencies:
- - '@sveltejs/kit'
- - '@swc/core'
- - edgedriver
- - esbuild
- - geckodriver
- - msw
- - rolldown
- - sharp
- - svelte-preprocess
- - vue-demi
peerDependencyRules:
ignoreMissing:
- '@algolia/client-search'
+allowBuilds:
+ '@parcel/watcher': true
+ '@swc/core': true
+ edgedriver: true
+ esbuild: true
+ geckodriver: true
+ msw: true
+ protobufjs: true
+ sharp: true
+ vue-demi: true
diff --git a/test/ui/fixtures/merge-reports/linux/basic.test.ts b/test/ui/fixtures/merge-reports/linux/basic.test.ts
new file mode 100644
index 000000000000..b3b349a2d958
--- /dev/null
+++ b/test/ui/fixtures/merge-reports/linux/basic.test.ts
@@ -0,0 +1,5 @@
+import { test } from 'vitest'
+
+test('ok', async ({ annotate }) => {
+ await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`)
+})
diff --git a/test/ui/fixtures/merge-reports/linux/vitest.config.ts b/test/ui/fixtures/merge-reports/linux/vitest.config.ts
new file mode 100644
index 000000000000..b1c6ea436a54
--- /dev/null
+++ b/test/ui/fixtures/merge-reports/linux/vitest.config.ts
@@ -0,0 +1 @@
+export default {}
diff --git a/test/ui/fixtures/merge-reports/macos/basic.test.ts b/test/ui/fixtures/merge-reports/macos/basic.test.ts
new file mode 100644
index 000000000000..b3b349a2d958
--- /dev/null
+++ b/test/ui/fixtures/merge-reports/macos/basic.test.ts
@@ -0,0 +1,5 @@
+import { test } from 'vitest'
+
+test('ok', async ({ annotate }) => {
+ await annotate(`test-${process.env.TEST_LABEL ?? "unknown"}`)
+})
diff --git a/test/ui/fixtures/merge-reports/macos/vitest.config.ts b/test/ui/fixtures/merge-reports/macos/vitest.config.ts
new file mode 100644
index 000000000000..b1c6ea436a54
--- /dev/null
+++ b/test/ui/fixtures/merge-reports/macos/vitest.config.ts
@@ -0,0 +1 @@
+export default {}
diff --git a/test/ui/test/helper.ts b/test/ui/test/helper.ts
index 46efcab661ce..f41a3cf5f020 100644
--- a/test/ui/test/helper.ts
+++ b/test/ui/test/helper.ts
@@ -1,4 +1,4 @@
-import type { Page } from '@playwright/test'
+import type { Locator, Page } from '@playwright/test'
import type { InlineConfig, PreviewServer } from 'vite'
import type { CliOptions, Vitest } from 'vitest/node'
import assert from 'node:assert'
@@ -8,6 +8,14 @@ import { expect } from '@playwright/test'
import { preview } from 'vite'
import { startVitest } from 'vitest/node'
+export async function startVitestSimple(cliOptions: CliOptions): Promise {
+ const stdout = new Writable({ write: (_, __, callback) => callback() })
+ const stderr = new Writable({ write: (_, __, callback) => callback() })
+ const vitest = await startVitest('test', undefined, cliOptions, {}, { stdout, stderr })
+ await vitest.close()
+ return vitest
+}
+
export async function startVitestUi(
cliOptions: CliOptions,
viteOverrides: InlineConfig = {},
@@ -66,6 +74,10 @@ export async function openExplorerFileItem(page: Page, name: string) {
await item.getByTestId('btn-open-details').click()
}
+export function getAnnotation(locator: Page | Locator, message: string) {
+ return locator.getByRole('note').filter({ hasText: message })
+}
+
export async function assertDownloadAttachment(
page: Page,
options: {
@@ -74,7 +86,7 @@ export async function assertDownloadAttachment(
content: string
},
) {
- const annotation = page.getByRole('note').filter({ hasText: options.name })
+ const annotation = getAnnotation(page, options.name)
const downloadPromise = page.waitForEvent('download')
await annotation.getByRole('link').click()
const download = await downloadPromise
diff --git a/test/ui/test/merge-reports.spec.ts b/test/ui/test/merge-reports.spec.ts
new file mode 100644
index 000000000000..b03976b12f2c
--- /dev/null
+++ b/test/ui/test/merge-reports.spec.ts
@@ -0,0 +1,80 @@
+import type { PreviewServer } from 'vite'
+import { readdirSync, renameSync, rmSync } from 'node:fs'
+import path from 'node:path'
+import { expect, test } from '@playwright/test'
+import { getAnnotation, getExplorerItem, startHtmlReportPreview, startVitestSimple } from './helper'
+
+test.describe('html reporter', () => {
+ let previewServer: PreviewServer
+ let baseURL: string
+
+ test.beforeAll(async () => {
+ // Simulate CI uploads blobs from platform-specific jobs and merges them on
+ // a Linux job, so the merged report can reference source paths that do not
+ // exist on the machine generating the HTML report.
+
+ const baseDir = path.join(import.meta.dirname, '../fixtures/merge-reports')
+ const linuxRoot = path.join(baseDir, 'linux')
+ const macosRoot = path.join(baseDir, 'macos')
+ const linuxBlobDir = path.join(linuxRoot, '.vitest/blob')
+ const macosBlobDir = path.join(macosRoot, '.vitest/blob')
+
+ rmSync(linuxBlobDir, { force: true, recursive: true })
+ rmSync(macosBlobDir, { force: true, recursive: true })
+
+ await startVitestSimple({
+ root: linuxRoot,
+ reporters: [['blob', { label: 'linux' }]],
+ env: { TEST_LABEL: 'linux' },
+ })
+ await startVitestSimple({
+ root: macosRoot,
+ reporters: [['blob', { label: 'macos' }]],
+ env: { TEST_LABEL: 'macos' },
+ })
+
+ for (const filename of readdirSync(macosBlobDir)) {
+ renameSync(path.join(macosBlobDir, filename), path.join(linuxBlobDir, filename))
+ }
+
+ const server = await startHtmlReportPreview(
+ {
+ root: linuxRoot,
+ mergeReports: linuxBlobDir,
+ reporters: 'html',
+ },
+ {
+ root: linuxRoot,
+ build: { outDir: 'html' },
+ },
+ )
+
+ previewServer = server.previewServer
+ baseURL = `${server.url}/`
+ })
+
+ test.afterAll(async () => {
+ await previewServer?.close()
+ })
+
+ test('code from different root is available', async ({ page }) => {
+ await page.goto(baseURL)
+
+ const item1 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'linux' })
+ const item2 = getExplorerItem(page, 'basic.test.ts').filter({ hasText: 'macos' })
+ const editorButton = page.getByTestId('btn-code')
+ const editor = page.getByTestId('editor')
+
+ await item1.hover()
+ await item1.getByTestId('btn-open-details').click()
+ await editorButton.click()
+ await expect(editor).toContainText(`test('ok'`)
+ await expect(getAnnotation(editor, 'test-linux')).toBeVisible()
+
+ await item2.hover()
+ await item2.getByTestId('btn-open-details').click()
+ await editorButton.click()
+ await expect(editor).toContainText(`test('ok'`)
+ await expect(getAnnotation(editor, 'test-macos')).toBeVisible()
+ })
+})
diff --git a/test/unit/test/expect.test-d.ts b/test/unit/test/expect.test-d.ts
index 20e480bb980b..a37559ba4768 100644
--- a/test/unit/test/expect.test-d.ts
+++ b/test/unit/test/expect.test-d.ts
@@ -119,4 +119,24 @@ test('expect.* allows asymmetrict mattchers with different types', () => {
expect(value).toMatchObject(value)
}
expectMany({ enabled: true, data: 'ok' })
+
+ // toBeOneOf accepts readonly arrays and sets
+ // https://github.com/vitest-dev/vitest/issues/10264
+ {
+ const allowed = ['A', 'B'] as const
+ expect('A').toBeOneOf(allowed)
+ expect('A').toEqual(expect.toBeOneOf(allowed))
+
+ const readonlyArray: ReadonlyArray = ['A', 'B']
+ expect('A').toBeOneOf(readonlyArray)
+ expect('A').toEqual(expect.toBeOneOf(readonlyArray))
+
+ const readonlySet: ReadonlySet = new Set(['A', 'B'])
+ expect('A').toBeOneOf(readonlySet)
+ expect('A').toEqual(expect.toBeOneOf(readonlySet))
+
+ // mutable arrays and sets still work
+ expect('A').toBeOneOf(['A', 'B'])
+ expect('A').toBeOneOf(new Set(['A', 'B']))
+ }
})
diff --git a/test/unit/test/fixtures/timers.suite.ts b/test/unit/test/fixtures/timers.suite.ts
index 1f26dbedd685..9122734aec30 100644
--- a/test/unit/test/fixtures/timers.suite.ts
+++ b/test/unit/test/fixtures/timers.suite.ts
@@ -51,7 +51,7 @@ describe('FakeTimers', () => {
expect(global.clearInterval).not.toBe(undefined)
})
- it.skipIf(isChildProcess)('mocks process.nextTick if it exists on global', () => {
+ it.skipIf(isChildProcess)('mocks process.nextTick when toFake includes nextTick', () => {
const origNextTick = () => {}
const global = {
Date: FakeDate,
@@ -81,7 +81,7 @@ describe('FakeTimers', () => {
expect(global.process.nextTick).toBe(origNextTick)
})
- it.runIf(isChildProcess)('throws when is child_process and tries to mock nextTick', () => {
+ it.runIf(isChildProcess)('throws when is child_process and toFake includes nextTick', () => {
const global = { Date: FakeDate, process, setTimeout, clearTimeout }
const timers = new FakeTimers({ global, config: { toFake: ['nextTick'] } })
@@ -135,6 +135,90 @@ describe('FakeTimers', () => {
expect(global.setImmediate).toBeUndefined();
expect(global.clearImmediate).toBeUndefined();
})
+
+ it('leaves listed methods native when toNotFake is used', () => {
+ const origSetTimeout = setTimeout
+ const origSetImmediate = () => {}
+ const origClearImmediate = () => {}
+ const global = {
+ Date: FakeDate,
+ clearImmediate: origClearImmediate,
+ clearTimeout,
+ setImmediate: origSetImmediate,
+ setTimeout,
+ }
+ const timers = new FakeTimers({ global, config: { toNotFake: ['setImmediate', 'clearImmediate', 'nextTick'] } })
+ timers.useFakeTimers()
+ expect(global.setTimeout).not.toBe(origSetTimeout)
+ expect(global.setImmediate).toBe(origSetImmediate)
+ expect(global.clearImmediate).toBe(origClearImmediate)
+ })
+
+ it.skipIf(isChildProcess)('mocks process.nextTick when toNotFake does not include nextTick', () => {
+ const origNextTick = () => {}
+ const global = {
+ Date: FakeDate,
+ clearTimeout,
+ process: {
+ nextTick: origNextTick,
+ },
+ setTimeout,
+ }
+ const timers = new FakeTimers({ global, config: { toNotFake: [] } })
+ timers.useFakeTimers()
+ expect(global.process.nextTick).not.toBe(origNextTick)
+ })
+
+ it.runIf(isChildProcess)('does not mock process.nextTick when toNotFake does not include nextTick and is child_process', () => {
+ const origNextTick = () => {}
+ const global = {
+ Date: FakeDate,
+ clearTimeout,
+ process: {
+ nextTick: origNextTick,
+ },
+ setTimeout,
+ }
+ const timers = new FakeTimers({ global, config: { toNotFake: [] } })
+ timers.useFakeTimers()
+ expect(global.process.nextTick).toBe(origNextTick)
+ })
+
+ it.skipIf(isChildProcess)('does not mock process.nextTick when toNotFake includes nextTick', () => {
+ const origNextTick = () => {}
+ const global = {
+ Date: FakeDate,
+ clearTimeout,
+ process: {
+ nextTick: origNextTick,
+ },
+ setTimeout,
+ }
+ const timers = new FakeTimers({ global, config: { toNotFake: ['nextTick'] } })
+ timers.useFakeTimers()
+ expect(global.process.nextTick).toBe(origNextTick)
+ })
+
+ it("toFake and toNotFake cannot be used together", () => {
+ const global = {
+ Date: FakeDate,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ }
+ const timers = new FakeTimers({
+ global,
+ config: {
+ toFake: [],
+ toNotFake: [],
+ },
+ })
+ expect(() => timers.useFakeTimers())
+ .toThrowErrorMatchingInlineSnapshot(
+ `[TypeError: config.toFake and config.toNotFake cannot be used together]`
+ )
+ })
})
describe('runAllTicks', () => {
@@ -1138,8 +1222,8 @@ describe('FakeTimers', () => {
'mock1',
'mock2',
'mock2',
- 'mock3',
'mock5',
+ 'mock3',
'mock1',
'mock2',
])
@@ -1246,8 +1330,8 @@ describe('FakeTimers', () => {
'mock1',
'mock2',
'mock2',
- 'mock3',
'mock5',
+ 'mock3',
'mock1',
'mock2',
])