Skip to content

Commit e04f0da

Browse files
feat(vitest-plugin): support vitest 5 alongside 3 and 4
Vitest 5 reworked the benchmark backend: the dedicated `NodeBenchmarkRunner` and the `vitest/runners` / `vitest/suite` entrypoints are gone, benchmarks now run inside `test()` through the unified `TestRunner`, and tinybench moved to v6 (stats moved from `result.benchmark` to `task.result.latency`, `includeSamples` became `retainSamples`). Detect the installed Vitest generation and select the integration seam behind a `VitestBackend` abstraction so the rest of the plugin never inspects the version: - v3/4 keep the custom benchmark runner per instrument mode (analysis/walltime). - v5 installs instrumentation from a setup file that patches the shared `TestRunner.runBenchmarks` static. A setup file (rather than a custom `test.runner`) leaves the runner untouched for non-benchmark tests and also applies to the browser pool.
1 parent 1999ec9 commit e04f0da

19 files changed

Lines changed: 1161 additions & 516 deletions

packages/vitest-plugin/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@
4141
"peerDependencies": {
4242
"tinybench": ">=2.9.0",
4343
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
44-
"vitest": "^3.2 || ^4"
44+
"vitest": "^3.2 || ^4 || ^5.0.0-beta"
4545
},
4646
"devDependencies": {
4747
"@total-typescript/shoehorn": "^0.1.1",
4848
"execa": "^8.0.1",
4949
"tinybench": "^2.9.0",
5050
"vite": "^7.0.0",
51-
"vitest": "^4.0.18"
51+
"vitest": "5.0.0-beta.5"
5252
}
5353
}

packages/vitest-plugin/rollup.config.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@ export default defineConfig([
2020
plugins: jsPlugins(pkg.version),
2121
external: ["@codspeed/core", /^vitest/],
2222
},
23+
// The built layout mirrors the source layout (dist/legacy/*, dist/v5/*) so the
24+
// plugin resolves the seam files with one path rule in both dev and prod.
2325
{
24-
input: "src/analysis.ts",
25-
output: { file: "dist/analysis.mjs", format: "es" },
26+
input: "src/legacy/analysis.ts",
27+
output: { file: "dist/legacy/analysis.mjs", format: "es" },
2628
plugins: jsPlugins(pkg.version),
2729
external: ["@codspeed/core", /^vitest/],
2830
},
2931
{
30-
input: "src/walltime/index.ts",
31-
output: { file: "dist/walltime.mjs", format: "es" },
32+
input: "src/legacy/walltime.ts",
33+
output: { file: "dist/legacy/walltime.mjs", format: "es" },
3234
plugins: jsPlugins(pkg.version),
3335
external: ["@codspeed/core", /^vitest/],
3436
},
37+
{
38+
input: "src/v5/setup.ts",
39+
output: { file: "dist/v5/setup.mjs", format: "es" },
40+
plugins: jsPlugins(pkg.version),
41+
external: ["@codspeed/core", /^vitest/, "tinybench"],
42+
},
3543
]);

packages/vitest-plugin/src/__tests__/index.test.ts

Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ vi.mock("fs", () => {
4343

4444
console.warn = vi.fn();
4545

46+
const EXPECTED_EXEC_ARGV = [
47+
"--interpreted-frames-native-stack",
48+
"--allow-natives-syntax",
49+
"--hash-seed=1",
50+
"--random-seed=1",
51+
"--no-opt",
52+
"--predictable",
53+
"--predictable-gc-schedule",
54+
"--expose-gc",
55+
"--no-concurrent-sweeping",
56+
"--max-old-space-size=4096",
57+
];
58+
4659
describe("codSpeedPlugin", () => {
4760
beforeAll(() => {
4861
// Set environment variables to trigger instrumented mode
@@ -54,6 +67,7 @@ describe("codSpeedPlugin", () => {
5467
// Clean up environment variables
5568
delete process.env.CODSPEED_ENV;
5669
delete process.env.CODSPEED_RUNNER_MODE;
70+
fsMocks.setMockVersion("4.0.18");
5771
});
5872

5973
it("should have a name", async () => {
@@ -65,7 +79,9 @@ describe("codSpeedPlugin", () => {
6579
});
6680

6781
describe("apply", () => {
68-
it("should not apply the plugin when the mode is not benchmark", async () => {
82+
it("should not apply the plugin when the mode is not benchmark (v3/v4)", async () => {
83+
fsMocks.setMockVersion("4.0.18");
84+
6985
const applyPlugin = applyPluginFunction(
7086
{},
7187
fromPartial({ mode: "test" }),
@@ -74,7 +90,8 @@ describe("codSpeedPlugin", () => {
7490
expect(applyPlugin).toBe(false);
7591
});
7692

77-
it("should apply the plugin when there is no instrumentation", async () => {
93+
it("should apply the plugin when there is no instrumentation (v3/v4)", async () => {
94+
fsMocks.setMockVersion("4.0.18");
7895
coreMocks.InstrumentHooks.isInstrumented.mockReturnValue(false);
7996

8097
const applyPlugin = applyPluginFunction(
@@ -88,7 +105,8 @@ describe("codSpeedPlugin", () => {
88105
expect(applyPlugin).toBe(true);
89106
});
90107

91-
it("should apply the plugin when there is instrumentation", async () => {
108+
it("should apply the plugin when there is instrumentation (v3/v4)", async () => {
109+
fsMocks.setMockVersion("4.0.18");
92110
coreMocks.InstrumentHooks.isInstrumented.mockReturnValue(true);
93111

94112
const applyPlugin = applyPluginFunction(
@@ -98,51 +116,60 @@ describe("codSpeedPlugin", () => {
98116

99117
expect(applyPlugin).toBe(true);
100118
});
119+
120+
it("should stay active regardless of mode on v5 (benchmark gating happens in config)", async () => {
121+
fsMocks.setMockVersion("5.0.0-beta.5");
122+
coreMocks.InstrumentHooks.isInstrumented.mockReturnValue(true);
123+
124+
const applyPlugin = applyPluginFunction(
125+
{},
126+
fromPartial({ mode: "test" }),
127+
);
128+
129+
expect(applyPlugin).toBe(true);
130+
fsMocks.setMockVersion("4.0.18");
131+
});
101132
});
102133

103134
it("should apply the codspeed config for v4", () => {
135+
fsMocks.setMockVersion("4.0.18");
104136
const config = resolvedCodSpeedPlugin.config;
105137
if (typeof config !== "function")
106138
throw new Error("config is not a function");
107139

108-
const result = config.call({} as never, {}, fromPartial({}));
140+
const result = config.call(
141+
{} as never,
142+
{},
143+
fromPartial({ mode: "benchmark" }),
144+
);
109145

110146
expect(result).toStrictEqual({
111147
test: {
112148
globalSetup: [
113149
expect.stringContaining("packages/vitest-plugin/src/globalSetup.ts"),
114150
],
115151
pool: "forks",
116-
execArgv: [
117-
"--interpreted-frames-native-stack",
118-
"--allow-natives-syntax",
119-
"--hash-seed=1",
120-
"--random-seed=1",
121-
"--no-opt",
122-
"--predictable",
123-
"--predictable-gc-schedule",
124-
"--expose-gc",
125-
"--no-concurrent-sweeping",
126-
"--max-old-space-size=4096",
127-
],
152+
execArgv: EXPECTED_EXEC_ARGV,
128153
runner: expect.stringContaining(
129-
"packages/vitest-plugin/src/analysis.ts",
154+
"packages/vitest-plugin/src/legacy/analysis.ts",
130155
),
131156
},
132157
});
133158
});
134159

135160
it("should apply the codspeed config for v3 with poolOptions", () => {
136-
// Set mock version to v3
137161
fsMocks.setMockVersion("3.2.0");
138162

139-
// Create a new plugin instance to pick up the mocked version
140163
const v3Plugin = codspeedPlugin();
141164
const config = v3Plugin.config;
142165
if (typeof config !== "function")
143166
throw new Error("config is not a function");
144167

145-
const result = config.call({} as never, {}, fromPartial({}));
168+
const result = config.call(
169+
{} as never,
170+
{},
171+
fromPartial({ mode: "benchmark" }),
172+
);
146173

147174
expect(result).toStrictEqual({
148175
test: {
@@ -152,27 +179,70 @@ describe("codSpeedPlugin", () => {
152179
pool: "forks",
153180
poolOptions: {
154181
forks: {
155-
execArgv: [
156-
"--interpreted-frames-native-stack",
157-
"--allow-natives-syntax",
158-
"--hash-seed=1",
159-
"--random-seed=1",
160-
"--no-opt",
161-
"--predictable",
162-
"--predictable-gc-schedule",
163-
"--expose-gc",
164-
"--no-concurrent-sweeping",
165-
"--max-old-space-size=4096",
166-
],
182+
execArgv: EXPECTED_EXEC_ARGV,
167183
},
168184
},
169185
runner: expect.stringContaining(
170-
"packages/vitest-plugin/src/analysis.ts",
186+
"packages/vitest-plugin/src/legacy/analysis.ts",
171187
),
172188
},
173189
});
174190

175-
// Reset mock version back to v4
176191
fsMocks.setMockVersion("4.0.18");
177192
});
193+
194+
describe("v5 config", () => {
195+
it("should not inject config when benchmarks are not enabled", () => {
196+
fsMocks.setMockVersion("5.0.0-beta.5");
197+
const v5Plugin = codspeedPlugin();
198+
const config = v5Plugin.config;
199+
if (typeof config !== "function")
200+
throw new Error("config is not a function");
201+
202+
const result = config.call(
203+
{} as never,
204+
{},
205+
fromPartial({ mode: "test" }),
206+
);
207+
208+
expect(result).toBeUndefined();
209+
fsMocks.setMockVersion("4.0.18");
210+
});
211+
212+
it("should inject the v5 setup file (not a runner) when benchmarks are enabled", () => {
213+
fsMocks.setMockVersion("5.0.0-beta.5");
214+
const v5Plugin = codspeedPlugin();
215+
const config = v5Plugin.config;
216+
if (typeof config !== "function")
217+
throw new Error("config is not a function");
218+
219+
const result = config.call(
220+
{} as never,
221+
// `benchmark.enabled` is a Vitest 5 config field the v3/4 typings (which
222+
// this file may be compiled against) don't expose.
223+
{ test: { benchmark: { enabled: true } } } as never,
224+
fromPartial({ mode: "test" }),
225+
);
226+
227+
expect(result).toStrictEqual({
228+
test: {
229+
globalSetup: [
230+
expect.stringContaining(
231+
"packages/vitest-plugin/src/globalSetup.ts",
232+
),
233+
],
234+
pool: "forks",
235+
execArgv: EXPECTED_EXEC_ARGV,
236+
setupFiles: [
237+
expect.stringContaining("packages/vitest-plugin/src/v5/setup.ts"),
238+
],
239+
},
240+
});
241+
// The v5 path must not set a custom runner.
242+
expect(
243+
(result as { test?: { runner?: unknown } })?.test?.runner,
244+
).toBeUndefined();
245+
fsMocks.setMockVersion("4.0.18");
246+
});
247+
});
178248
});

packages/vitest-plugin/src/__tests__/instrumented.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { fromPartial } from "@total-typescript/shoehorn";
22
import { describe, expect, it, vi, type RunnerTestSuite } from "vitest";
3+
// `vitest/suite` only exists on Vitest 3/4; this file is excluded from the test
4+
// run under v5+ (see vitest.config.ts).
5+
// eslint-disable-next-line import/no-unresolved
36
import { getBenchFn } from "vitest/suite";
4-
import { AnalysisRunner as CodSpeedRunner } from "../analysis";
7+
import { AnalysisRunner as CodSpeedRunner } from "../legacy/analysis";
8+
9+
// The legacy AnalysisRunner targets the Vitest 3/4 benchmark backend
10+
// (`NodeBenchmarkRunner`, `vitest/suite`), which Vitest 5 removed. This whole
11+
// file is excluded from the test run under v5+ (see vitest.config.ts); the v5
12+
// path is covered separately.
513

614
const coreMocks = vi.hoisted(() => {
715
return {

packages/vitest-plugin/src/index.ts

Lines changed: 20 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,49 +7,33 @@ import {
77
SetupInstrumentsRequestBody,
88
SetupInstrumentsResponse,
99
} from "@codspeed/core";
10-
import { readFileSync } from "fs";
11-
import { createRequire } from "module";
1210
import { join } from "path";
1311
import { Plugin } from "vite";
1412
import { type ViteUserConfig } from "vitest/config";
13+
import { resolveVitestBackend } from "./vitestBackend";
1514

1615
// get this file's directory path from import.meta.url
1716
const __dirname = new URL(".", import.meta.url).pathname;
1817
const isFileInTs = import.meta.url.endsWith(".ts");
1918

20-
function getCodSpeedFileFromName(name: string) {
19+
/**
20+
* Resolve a plugin-owned file (globalSetup, seam entry points) shipped alongside
21+
* this module. Source (`.ts`) and built (`.mjs`) layouts are kept identical (see
22+
* rollup.config.ts), so the same relative `name` works in both.
23+
*/
24+
function resolveFile(name: string): string {
2125
const fileExtension = isFileInTs ? "ts" : "mjs";
22-
2326
return join(__dirname, `${name}.${fileExtension}`);
2427
}
2528

26-
function getVitestMajorVersion(): number | null {
27-
try {
28-
// Resolve vitest from the project's perspective (cwd), not from the plugin's location
29-
// This ensures we detect the vitest version the user has installed
30-
const require = createRequire(join(process.cwd(), "package.json"));
31-
const vitestPkgPath = require.resolve("vitest/package.json");
32-
const vitestPkg = JSON.parse(readFileSync(vitestPkgPath, "utf-8"));
33-
return parseInt(vitestPkg.version.split(".")[0], 10);
34-
} catch {
35-
return null;
36-
}
37-
}
38-
39-
function getRunnerFile(): string | undefined {
40-
const instrumentMode = getInstrumentMode();
41-
if (instrumentMode === "disabled") {
42-
return undefined;
43-
}
44-
45-
return getCodSpeedFileFromName(instrumentMode);
46-
}
47-
4829
export default function codspeedPlugin(): Plugin {
30+
// Resolved lazily on each hook rather than once here: the installed Vitest
31+
// version is detected from the project's cwd, which isn't reliably knowable at
32+
// plugin-construction time (and tests swap it between construction and use).
4933
return {
5034
name: "codspeed:vitest",
5135
apply(_, { mode }) {
52-
if (mode !== "benchmark") {
36+
if (!resolveVitestBackend().isActiveForViteMode(mode)) {
5337
return false;
5438
}
5539
if (
@@ -61,37 +45,19 @@ export default function codspeedPlugin(): Plugin {
6145
return true;
6246
},
6347
enforce: "post",
64-
config(): ViteUserConfig {
65-
const runnerFile = getRunnerFile();
66-
const runnerMode = getCodspeedRunnerMode();
67-
const v8Flags = getV8Flags();
68-
const vitestMajorVersion = getVitestMajorVersion();
69-
// by default, assume Vitest v4 or higher
70-
const isVitestV4OrHigher = (vitestMajorVersion ?? 4) >= 4;
48+
config(incomingConfig, { mode }): ViteUserConfig | undefined {
49+
const backend = resolveVitestBackend();
50+
if (!backend.isBenchmarkRun(incomingConfig, mode)) {
51+
return undefined;
52+
}
7153

7254
const config: ViteUserConfig = {
7355
test: {
7456
pool: "forks",
75-
...(isVitestV4OrHigher
76-
? { execArgv: v8Flags }
77-
: {
78-
// Compat with Vitest v3
79-
// See: https://vitest.dev/guide/migration.html#pool-rework
80-
// poolOptions only exists in Vitest v3
81-
poolOptions: {
82-
forks: {
83-
execArgv: v8Flags,
84-
},
85-
},
86-
}),
87-
globalSetup: [getCodSpeedFileFromName("globalSetup")],
88-
...(runnerFile && {
89-
runner: runnerFile,
90-
}),
91-
...(runnerMode === "walltime" && {
92-
benchmark: {
93-
includeSamples: true,
94-
},
57+
globalSetup: [resolveFile("globalSetup")],
58+
...backend.getBenchmarkTestConfig(getV8Flags(), resolveFile),
59+
...(getCodspeedRunnerMode() === "walltime" && {
60+
benchmark: backend.getWalltimeBenchmarkConfig(),
9561
}),
9662
},
9763
};

0 commit comments

Comments
 (0)