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"],