name: CI # Remove default permissions of GITHUB_TOKEN for security # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs permissions: {} on: push: branches: - main pull_request: types: [opened, synchronize, reopened, closed] workflow_dispatch: concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright # Trust the committed lockfile and skip pnpm's per-install supply-chain # verification (trustPolicy: no-downgrade, ~15-20s on every job). The lint job # overrides this to false so the lockfile is still verified once per run. PNPM_CONFIG_TRUST_LOCKFILE: true jobs: lint: if: github.event.action != 'closed' timeout-minutes: 10 runs-on: ubuntu-latest name: 'Lint: node-latest, ubuntu-latest' # verify the lockfile against supply-chain policies once per run env: PNPM_CONFIG_TRUST_LOCKFILE: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache - name: Install run: pnpm i --filter '!docs' - name: Build run: pnpm run build - name: Generate CLI docs run: pnpm -C docs run cli-table # check uncommited LICENSE.md, auto-imports.d.ts, etc... - name: Check stale build artifacts run: git diff --exit-code - name: Lint run: pnpm run lint - name: Typecheck run: pnpm run typecheck - name: Typecheck UI run: pnpm run -C packages/ui typecheck:client # From https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions - name: Check workflow files run: | bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -color -shellcheck="" changed: if: github.event.action != 'closed' runs-on: ubuntu-latest name: 'Diff: node-latest, ubuntu-latest' outputs: should_skip: ${{ steps.changed-files.outputs.only_changed == 'true' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Get changed files id: changed-files uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | docs/** .github/** !.github/workflows/ci.yml !.github/docker/** **.md # Ensures the playwright-deps image exists in GHCR: a no-op manifest check # when the tag for the locked Playwright version + Dockerfile hash already # exists, otherwise builds and pushes it. Fork PRs get a read-only token and # cannot push a missing tag; changes to the tag inputs must come from a # branch in this repository. playwright-deps-image: if: github.event.action != 'closed' runs-on: ubuntu-latest name: 'Image: playwright-deps, ubuntu-latest' timeout-minutes: 15 permissions: packages: write # push the playwright-deps image to GHCR outputs: image: ${{ steps.image.outputs.image }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Resolve image tag id: image run: | version="$(node -e " const fs = require('fs'); const lockfile = fs.readFileSync('./pnpm-lock.yaml', 'utf8'); const match = lockfile.match(/playwright:\s+specifier: [\s\w.^]+version: (\d+\.\d+\.\d+)/); if (!match) throw new Error('Failed to resolve playwright version'); console.log(match[1]); ")" hash="$(sha256sum .github/docker/Dockerfile | cut -c1-12)" echo "image=ghcr.io/${GITHUB_REPOSITORY@L}/playwright-deps:${version}-${hash}" >> "$GITHUB_OUTPUT" echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Log in to GHCR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - name: Build and push if missing env: IMAGE: ${{ steps.image.outputs.image }} PLAYWRIGHT_VERSION: ${{ steps.image.outputs.version }} run: | if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then echo "$IMAGE already exists, skipping build" else docker build --build-arg "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" -t "$IMAGE" .github/docker if ! docker push "$IMAGE"; then echo "::error::Failed to publish $IMAGE. Fork PRs cannot push to GHCR - a Vitest maintainer needs to run CI from a vitest-dev/vitest branch to publish the playwright-deps:$PLAYWRIGHT_VERSION image." exit 1 fi fi test: needs: changed name: 'Test: ${{ matrix.suite }}, node-${{ matrix.node_version }}, ${{ matrix.os }}' if: needs.changed.outputs.should_skip != 'true' runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: # Split the heavy e2e and coverage suites onto their own runners so they run # in parallel with the unit suite instead of sequentially on a single machine. # Linux runs the full matrix on node 24/26; node 22 (the minimum supported # version, currently in maintenance LTS) gets a single e2e leg as a floor # smoke. macOS runs one e2e leg as an Apple-hardware smoke; unit/coverage are # redundant on both. matrix: suite: [unit, e2e, coverage] os: [ubuntu-latest] node_version: [24, 26] include: - suite: e2e os: ubuntu-latest node_version: 22 - suite: e2e os: macos-latest node_version: 24 - suite: unit os: windows-latest node_version: 24 - suite: e2e os: windows-latest node_version: 24 - suite: coverage os: windows-latest node_version: 24 fail-fast: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache with: node-version: ${{ matrix.node_version }} - name: Install run: pnpm i --filter '!docs' - uses: ./.github/actions/setup-playwright with: browsers: chromium - name: Build run: pnpm run build - name: Test # matrix.suite is a fixed, workflow-defined value (unit/e2e/coverage) run: pnpm run test:ci:${{ matrix.suite }} # zizmor: ignore[template-injection] env: VITEST_CI_BLOB_LABEL: ${{ matrix.os }}-node-${{ matrix.node_version }} - name: Test Examples if: matrix.suite == 'unit' && matrix.os == 'ubuntu-latest' run: pnpm run test:examples - name: Unit Test UI if: matrix.suite == 'unit' run: pnpm -C packages/ui test:ui env: VITEST_CI_BLOB_LABEL: ${{ matrix.os }}-node-${{ matrix.node_version }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: name: vitest-results-${{ matrix.suite }}-${{ matrix.os }}-node-${{ matrix.node_version }} path: | README.md test/unit/.vitest test/e2e/.vitest packages/ui/.vitest retention-days: 1 include-hidden-files: true - name: Upload Playwright traces uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() && matrix.suite == 'unit' }} with: name: playwright-traces-${{ matrix.os }}-node-${{ matrix.node_version }} path: test/ui/test-results/**/trace.zip if-no-files-found: ignore retention-days: 1 # runner.test.ts is the heaviest spec (~28% of the suite, ~70% of whichever # shard it lands in). Windows isolates it in its own job and shards the other # 32 specs; Ubuntu shards the whole suite 2 ways (runner rides in one shard). # Every spec, runner included, runs on both OSes. Ubuntu runs inside the # playwright-deps container so `playwright install-deps` is never needed; # Windows needs no system deps. test-browser-windows: needs: changed name: 'Browsers: ${{ matrix.name }}, node-24, windows-latest' if: needs.changed.outputs.should_skip != 'true' runs-on: windows-latest strategy: matrix: include: - name: runner command: pnpm run test:browser:playwright runner.test.ts - name: rest 1/2 command: "pnpm run test:browser:playwright --exclude 'specs/runner.test.ts' --shard=1/2" - name: rest 2/2 command: "pnpm run test:browser:playwright --exclude 'specs/runner.test.ts' --shard=2/2" fail-fast: false timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache with: node-version: 24 - name: Install run: pnpm i --filter '!docs' - uses: ./.github/actions/setup-playwright with: browsers: all - name: Build run: pnpm run build - name: Test Browser (playwright) # matrix.command is a fixed, workflow-defined string run: ${{ matrix.command }} # zizmor: ignore[template-injection] test-browser-linux: needs: - changed - playwright-deps-image name: 'Browsers: ${{ matrix.name }}, node-24, ubuntu-latest' if: needs.changed.outputs.should_skip != 'true' runs-on: ubuntu-latest permissions: packages: read # pull the playwright-deps image from GHCR container: # the tag is version-keyed, built by playwright-deps-image in this workflow image: ${{ needs.playwright-deps-image.outputs.image }} # zizmor: ignore[unpinned-images] credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} options: --user 1001 strategy: matrix: include: - name: shard 1/2 command: pnpm run test:browser:playwright --shard=1/2 - name: shard 2/2 command: pnpm run test:browser:playwright --shard=2/2 fail-fast: false timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache with: node-version: 24 - name: Install run: pnpm i --filter '!docs' - uses: ./.github/actions/setup-playwright with: browsers: all - name: Build run: pnpm run build - name: Test Browser (playwright) # matrix.command is a fixed, workflow-defined string run: ${{ matrix.command }} # zizmor: ignore[template-injection] test-vite7: needs: - changed - playwright-deps-image # Runs on ubuntu to keep macOS under its ~5 concurrent-runner limit. name: 'Test: vite@7, ${{ matrix.suite }}, node-24, ubuntu-latest' if: needs.changed.outputs.should_skip != 'true' runs-on: ubuntu-latest permissions: packages: read # pull the playwright-deps image from GHCR container: # the tag is version-keyed, built by playwright-deps-image in this workflow image: ${{ needs.playwright-deps-image.outputs.image }} # zizmor: ignore[unpinned-images] credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} options: --user 1001 timeout-minutes: 30 strategy: # vite7 is a compatibility smoke, not a coverage gate. Run the e2e suite # (which exercises real config resolution, transform and runs) and the # browser suite on separate runners in parallel. matrix: suite: [e2e, browser] fail-fast: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache with: node-version: 24 - name: Install run: | pnpm override-vite7 # the container user does not own the workspace volume, and checkout's safe.directory entry does not survive into later steps git config --global --add safe.directory "$GITHUB_WORKSPACE" # ubuntu runners have no default git identity; set one for this commit git add . && git -c user.email=ci@vitest.dev -c user.name=vitest-ci commit -m "ci" && pnpm i --prefer-offline --no-frozen-lockfile --filter '!docs' - uses: ./.github/actions/setup-playwright with: browsers: all - name: Build run: pnpm run build - name: Test E2E if: ${{ matrix.suite == 'e2e' }} run: pnpm run test:ci:e2e - name: Test Browser (playwright) if: ${{ matrix.suite == 'browser' && !cancelled() }} # smoke: runner.test.ts exercises the core browser-runner behaviour across # every instance, enough to catch vite7 browser-mode regressions run: pnpm run test:browser:playwright runner.test.ts merge-reports: needs: - test - changed if: needs.changed.outputs.should_skip != 'true' && !cancelled() runs-on: ubuntu-latest name: Merge Reports timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-and-cache - name: Install run: pnpm i --filter '!docs' - name: Build run: pnpm run build - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: vitest-results-* merge-multiple: true - name: Merge reports continue-on-error: true run: | set +e pnpm --filter=./packages/ui --no-bail test:ui --merge-reports cp ./packages/ui/.vitest/index.html ./packages/ui/.vitest/test-ui.html pnpm --filter=./test/unit --filter=./test/e2e --no-bail --sequential test --merge-reports cp -rf ./test/unit/.vitest/index.html ./test/unit/.vitest/test-unit.html cp -rf ./test/e2e/.vitest/index.html ./test/e2e/.vitest/test-e2e.html env: VITEST_CI_MERGE_REPORTS: 'true' - name: Upload Test UI Report id: upload-ui-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: path: ./packages/ui/.vitest/test-ui.html archive: false retention-days: 7 - name: Upload Test Unit Report id: upload-unit-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: path: ./test/unit/.vitest/test-unit.html archive: false retention-days: 7 - name: Upload Test E2E Report id: upload-e2e-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: path: ./test/e2e/.vitest/test-e2e.html archive: false retention-days: 7 - name: Link CI reports run: | echo "::notice title=CI reports::test/unit: ${STEPS_UPLOAD_UNIT_REPORT_OUTPUTS_ARTIFACT_URL}" echo "::notice title=CI reports::test/e2e: ${STEPS_UPLOAD_E2E_REPORT_OUTPUTS_ARTIFACT_URL}" echo "::notice title=CI reports::packages/ui: ${STEPS_UPLOAD_UI_REPORT_OUTPUTS_ARTIFACT_URL}" env: STEPS_UPLOAD_UNIT_REPORT_OUTPUTS_ARTIFACT_URL: ${{ steps.upload-unit-report.outputs.artifact-url }} STEPS_UPLOAD_E2E_REPORT_OUTPUTS_ARTIFACT_URL: ${{ steps.upload-e2e-report.outputs.artifact-url }} STEPS_UPLOAD_UI_REPORT_OUTPUTS_ARTIFACT_URL: ${{ steps.upload-ui-report.outputs.artifact-url }}