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', () => { Benchmark report Benchmark report -## 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', ])