From 57255a945259c33e8013f9924259fed98f26a29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sun, 4 Oct 2026 09:42:19 +0200 Subject: [PATCH] fix(vitest): make argosScreenshot and auto-naming work on Vitest 4.0 On Vitest 4.0.x in browser mode, every argosScreenshot() call rejected with "Failed to resolve module specifier 'vitest/suite'". Without `TestRunner`, the SDK imported `vitest/suite` through a @vite-ignore'd dynamic import, and Vite leaves that bare specifier unresolved for the browser. It now reads the running test from Vitest's worker state (`__vitest_worker__.current`), where Vitest 4.0's own page.screenshot() reads it, in Node and in the browser alike. Tasks of Vitest 4.0.0 to 4.0.13 also have no `fullName`, so automatic names threw "Cannot read properties of undefined (reading 'startsWith')" in Node and in the browser. The name is rebuilt from the title path there, which gives the same name as `fullName` on later versions. CI only tested the latest 4.x, which has both. The compat job now also runs the e2e suite on Vitest 4.0.0, the oldest supported release, under its own build name. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++++++++--- packages/vitest/package.json | 2 +- packages/vitest/src/auto-name.test.ts | 23 +++++++++++++- packages/vitest/src/auto-name.ts | 10 ++++-- packages/vitest/src/metadata.ts | 2 +- packages/vitest/src/test-context.ts | 45 +++++++++++++++++---------- turbo.json | 1 + 7 files changed, 73 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee2272e..78f2e793 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,8 +300,8 @@ jobs: # `@argos-ci/vitest` supports Vitest 4 and 5. The workspace is pinned to 5, so # these two jobs put 4 back and re-run the same suites against it. They upload - # under their own build names — a build name owns one baseline, and the two - # majors are separate baselines even when the pixels agree. + # under their own build names — a build name owns one baseline, and each + # Vitest version tested is a separate baseline even when the pixels agree. e2e-vitest-compat: timeout-minutes: 5 strategy: @@ -309,6 +309,10 @@ jobs: matrix: node-version: [lts/*] os: [ubuntu-latest] + # 4.0.0 is the oldest supported release: it predates `TestRunner` and + # the task `fullName`, so it runs the SDK's fallbacks. `4` is the latest + # 4.x. + vitest-version: ["4.0.0", "4"] runs-on: ${{ matrix.os }} @@ -321,10 +325,13 @@ jobs: with: node-version: ${{ matrix.node-version }} - - name: Downgrade to Vitest 4 + - name: Downgrade to Vitest ${{ matrix.vitest-version }} run: >- - pnpm --filter=@argos-ci/vitest up "vitest@^4" "@vitest/browser@^4" - "@vitest/browser-playwright@^4" "@vitest/pretty-format@^4" + pnpm --filter=@argos-ci/vitest up + "vitest@${{ matrix.vitest-version }}" + "@vitest/browser@${{ matrix.vitest-version }}" + "@vitest/browser-playwright@${{ matrix.vitest-version }}" + "@vitest/pretty-format@${{ matrix.vitest-version }}" - name: Run integration tests run: pnpm exec -- turbo run e2e-compat --filter=@argos-ci/vitest @@ -332,6 +339,7 @@ jobs: ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} NODE_VERSION: ${{ matrix.node-version }} OS: ${{ matrix.os }} + VITEST_VERSION: ${{ matrix.vitest-version }} e2e-storybook-vitest-compat: timeout-minutes: 10 diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 14bce7e4..7a090a4a 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -77,7 +77,7 @@ "install-playwright": "playwright install chromium --with-deps", "build-e2e": "pnpm run install-playwright", "e2e": "cross-env BUILD_NAME=\"argos-vitest-e2e-node-$NODE_VERSION-$OS\" UPLOAD_TO_ARGOS=true pnpm run test-e2e", - "e2e-compat": "cross-env BUILD_NAME=\"argos-vitest4-e2e-node-$NODE_VERSION-$OS\" UPLOAD_TO_ARGOS=true pnpm run test-e2e", + "e2e-compat": "cross-env BUILD_NAME=\"argos-vitest$VITEST_VERSION-e2e-node-$NODE_VERSION-$OS\" UPLOAD_TO_ARGOS=true pnpm run test-e2e", "check-types": "tsc", "check-format": "prettier --check --ignore-unknown --ignore-path=./.gitignore --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .", "lint": "eslint ." diff --git a/packages/vitest/src/auto-name.test.ts b/packages/vitest/src/auto-name.test.ts index 45fb5563..e42cae6f 100644 --- a/packages/vitest/src/auto-name.test.ts +++ b/packages/vitest/src/auto-name.test.ts @@ -1,6 +1,13 @@ -import { expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { TestRunner } from "vitest"; import { resolveAutoName } from "./auto-name"; +import { getCurrentTest } from "./test-context"; + +// The real implementation, unless a test swaps in a task of its own. +vi.mock(import("./test-context"), async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getCurrentTest: vi.fn(actual.getCurrentTest) }; +}); it("returns an explicit name verbatim", async () => { await expect(resolveAutoName("button")).resolves.toBe("button"); @@ -22,6 +29,20 @@ it("restarts the counter for each test", async () => { await expect(resolveAutoName()).resolves.toBe(`${testName} 1`); }); +describe("on Vitest < 4.0.14", () => { + it("rebuilds the full name, which tasks do not have yet", async () => { + const test = TestRunner.getCurrentTest()!; + vi.mocked(getCurrentTest).mockResolvedValueOnce({ + id: test.id, + name: test.name, + file: test.file, + suite: test.suite, + }); + // Same name as from the real `fullName`, so upgrading Vitest keeps it. + await expect(resolveAutoName()).resolves.toBe(`${test.fullName} 1`); + }); +}); + it("includes the test file path so names are unique across files", async () => { const file = TestRunner.getCurrentTest()?.file?.name; expect(file).toBe("src/auto-name.test.ts"); diff --git a/packages/vitest/src/auto-name.ts b/packages/vitest/src/auto-name.ts index 2ee0c512..89ed43db 100644 --- a/packages/vitest/src/auto-name.ts +++ b/packages/vitest/src/auto-name.ts @@ -1,3 +1,4 @@ +import { getTitlePath } from "./metadata"; import { getCurrentTest } from "./test-context"; /** @@ -73,11 +74,14 @@ export async function resolveAutoName( const count = (counters.get(test) ?? 0) + 1; counters.set(test, count); + // Vitest sets `fullName` from 4.0.14 on; before that, its own `getFullName()` + // joined the title path. + const testFullName = test.fullName ?? getTitlePath(test).join(" > "); const file = test.file?.name; const fullName = - file && !test.fullName.startsWith(file) - ? `${file} > ${test.fullName}` - : test.fullName; + file && !testFullName.startsWith(file) + ? `${file} > ${testFullName}` + : testFullName; // Reserve room for the trailing counter and the caller's suffix, then keep // the whole name within the filesystem limit. The counter always survives diff --git a/packages/vitest/src/metadata.ts b/packages/vitest/src/metadata.ts index 0ac838e8..66ca48c2 100644 --- a/packages/vitest/src/metadata.ts +++ b/packages/vitest/src/metadata.ts @@ -16,7 +16,7 @@ export type TestMetadata = ScreenshotMetadata["test"]; * Build the title path of a task (`[file, ...describes, title]`), replicating * Vitest's own `getNames` helper so it matches the framework's conventions. */ -function getTitlePath(task: CurrentTask): string[] { +export function getTitlePath(task: CurrentTask): string[] { const names = [task.name]; let current: CurrentSuite = task; while (current.suite) { diff --git a/packages/vitest/src/test-context.ts b/packages/vitest/src/test-context.ts index ab5c7925..c811f142 100644 --- a/packages/vitest/src/test-context.ts +++ b/packages/vitest/src/test-context.ts @@ -14,7 +14,8 @@ export interface CurrentSuite { export interface CurrentTask extends CurrentSuite { id: string; name: string; - fullName: string; + /** Path of the file, describe blocks and title (Vitest >= 4.0.14). */ + fullName?: string | undefined; file: { name: string; filepath: string }; /** Tags declared on the test (Vitest >= 4). */ tags?: string[] | undefined; @@ -36,25 +37,38 @@ export interface CurrentTask extends CurrentSuite { } /** - * Entry point exposing `getCurrentTest` before Vitest 4.1, and removed - * altogether in Vitest 5. + * Global where Vitest keeps the state of the worker running the tests, in Node + * and in the browser alike. Vitest internal: only read on 4.0, a release line + * that no longer changes. + */ +const WORKER_STATE_GLOBAL = "__vitest_worker__"; + +/** + * Get the running test from Vitest's worker state, for Vitest 4.0. * - * Held in a variable rather than written inline at the import: Vite's - * dependency pre-bundler resolves a literal specifier eagerly, and on Vitest 5 - * that fails the whole optimize step over an export that no longer exists — - * even though the branch importing it cannot run there. + * Vitest 4.0 exports `getCurrentTest()` only from `vitest/suite`, which this + * package cannot import: written inline, the specifier fails Vite's dependency + * optimizer on Vitest 5, where that export is gone; hidden from Vite, it + * reaches the browser unresolved. The test runner also keeps the running task + * on the worker state, and Vitest 4.0's own browser commands + * (`page.screenshot()`) read it from there. The task is the test while it runs, + * hooks included, and its suite or file otherwise. */ -const LEGACY_SUITE_ENTRY = "vitest/suite"; +function getCurrentTestFromWorkerState(): CurrentTask | undefined { + const state = (globalThis as Record)[WORKER_STATE_GLOBAL] as + { current?: CurrentTask & { type?: string } } | undefined; + const task = state?.current; + return task?.type === "test" ? task : undefined; +} /** * Get the current Vitest test task, or `undefined` when not inside a test. * * Vitest >= 4.1 exposes `TestRunner.getCurrentTest()` from the `vitest` entry - * point; the `vitest/suite` export is deprecated there and gone in Vitest 5. We - * prefer the new API and fall back to `vitest/suite` for older 4.x. Both are - * imported dynamically so importing `@argos-ci/vitest` in a non-Vitest - * environment does not pull Vitest in — only call this once you know Vitest is - * available. + * point; on Vitest 4.0, we read the worker state instead (see + * {@link getCurrentTestFromWorkerState}). `vitest` is imported dynamically so + * importing `@argos-ci/vitest` in a non-Vitest environment does not pull Vitest + * in — only call this once you know Vitest is available. */ export async function getCurrentTest(): Promise { const vitest = (await import("vitest")) as { @@ -64,8 +78,5 @@ export async function getCurrentTest(): Promise { if (runner?.getCurrentTest) { return runner.getCurrentTest(); } - const suite = (await import(/* @vite-ignore */ LEGACY_SUITE_ENTRY)) as { - getCurrentTest: () => CurrentTask | undefined; - }; - return suite.getCurrentTest(); + return getCurrentTestFromWorkerState(); } diff --git a/turbo.json b/turbo.json index 516a7697..ff3daa87 100644 --- a/turbo.json +++ b/turbo.json @@ -71,6 +71,7 @@ "ARGOS_BUILD_NUMBER", "NODE_VERSION", "OS", + "VITEST_VERSION", "GITHUB_*" ], "dependsOn": ["build-e2e"],