Skip to content

Commit 301973e

Browse files
feat: add @codspeed/playwright package to bench electron apps
Introduce a @codspeed/playwright package exposing a Playwright-style `bench(name, fn, options)` API. The benchmark function receives a `{ page }` fixture mirroring Playwright's `test`, and a minimal options argument carrying the number of rounds and Electron launch config. Refs COD-2721 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a52e7ac commit 301973e

5 files changed

Lines changed: 404 additions & 6 deletions

File tree

packages/playwright/package.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@codspeed/playwright",
3+
"version": "5.4.0",
4+
"description": "Playwright benchmarking integration for CodSpeed",
5+
"keywords": [
6+
"codspeed",
7+
"benchmark",
8+
"playwright",
9+
"electron",
10+
"performance"
11+
],
12+
"main": "dist/index.cjs",
13+
"module": "dist/index.es5.js",
14+
"types": "dist/index.d.ts",
15+
"type": "module",
16+
"exports": {
17+
"types": "./dist/index.d.ts",
18+
"import": "./dist/index.es5.js",
19+
"require": "./dist/index.cjs"
20+
},
21+
"files": [
22+
"dist"
23+
],
24+
"scripts": {
25+
"build": "NODE_NO_WARNINGS=1 rollup -c rollup.config.ts --configPlugin typescript",
26+
"test": "echo 'no tests'",
27+
"test/integ": "echo 'no integ tests'",
28+
"lint": "eslint .",
29+
"typecheck": "tsc --noEmit --pretty",
30+
"format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check .",
31+
"fix-format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write .",
32+
"clean": "rm -rf dist"
33+
},
34+
"author": "Guillaume Lagrange <guillaume@codspeed.io>",
35+
"repository": "https://github.com/CodSpeedHQ/codspeed-node",
36+
"homepage": "https://codspeed.io",
37+
"license": "Apache-2.0",
38+
"devDependencies": {
39+
"playwright": "^1.48.0",
40+
"playwright-core": "^1.48.0",
41+
"vitest": "^3.2.4"
42+
},
43+
"dependencies": {
44+
"@codspeed/core": "workspace:^5.4.0"
45+
},
46+
"peerDependencies": {
47+
"playwright": ">=1.40.0"
48+
}
49+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { defineConfig } from "rollup";
2+
import { declarationsPlugin, jsPlugins } from "../../rollup.options";
3+
import pkg from "./package.json" assert { type: "json" };
4+
5+
const entrypoint = "src/index.ts";
6+
7+
export default defineConfig([
8+
{
9+
input: entrypoint,
10+
output: [
11+
{
12+
file: pkg.types,
13+
format: "es",
14+
sourcemap: true,
15+
},
16+
],
17+
plugins: declarationsPlugin({ compilerOptions: { composite: false } }),
18+
},
19+
{
20+
input: entrypoint,
21+
output: [
22+
{
23+
file: pkg.main,
24+
format: "cjs",
25+
sourcemap: true,
26+
},
27+
{ file: pkg.module, format: "es", sourcemap: true },
28+
],
29+
plugins: jsPlugins(pkg.version),
30+
external: ["@codspeed/core", "playwright", "playwright-core"],
31+
},
32+
]);

packages/playwright/src/index.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import {
2+
calculateQuantiles,
3+
getCallingFile,
4+
InstrumentHooks,
5+
MARKER_TYPE_BENCHMARK_END,
6+
MARKER_TYPE_BENCHMARK_START,
7+
msToS,
8+
writeWalltimeResults,
9+
type Benchmark,
10+
type BenchmarkStats,
11+
} from "@codspeed/core";
12+
import { createRequire } from "node:module";
13+
import { pathToFileURL } from "node:url";
14+
import { _electron as electron } from "playwright";
15+
import type { ElectronApplication, Page } from "playwright-core";
16+
17+
declare const __VERSION__: string;
18+
19+
const DEFAULT_ROUNDS = 1;
20+
const DEFAULT_PROFILING_JS_FLAGS =
21+
"--perf-prof --perf-prof-annotate-wasm --interpreted-frames-native-stack --no-turbo-inlining --no-sandbox";
22+
23+
/**
24+
* The fixtures passed to a benchmark function, mirroring Playwright's `test`
25+
* API. Currently exposes the Electron window as `page`.
26+
*/
27+
export interface BenchFixtures {
28+
page: Page;
29+
}
30+
31+
/**
32+
* The function whose execution is measured. Everything that runs inside it is
33+
* included in the reported timing.
34+
*/
35+
export type BenchFunction = (fixtures: BenchFixtures) => void | Promise<void>;
36+
37+
export type BenchHook = (fixtures: BenchFixtures) => void | Promise<void>;
38+
39+
/**
40+
* Minimal options for a benchmark. Inspired by Vitest's `bench`, but kept
41+
* deliberately small.
42+
*/
43+
export interface BenchOptions {
44+
/**
45+
* Number of measurement rounds to perform. Defaults to 1, can be overridden
46+
* via the `CODSPEED_ROUNDS` environment variable.
47+
*/
48+
rounds?: number;
49+
/**
50+
* Absolute path to the Electron main entrypoint (e.g. `out/main/index.js`).
51+
*/
52+
appPath: string;
53+
/**
54+
* CLI flags forwarded to Electron.
55+
*/
56+
electronArgs?: string[];
57+
/**
58+
* Working directory for the Electron process. Defaults to `process.cwd()`.
59+
*/
60+
cwd?: string;
61+
/**
62+
* Absolute path to the Electron executable. When omitted, it is resolved from
63+
* the `electron` package in `cwd`. Set this only to override that default.
64+
*/
65+
electronExecutablePath?: string;
66+
/**
67+
* Run before each round, after the window opens. Use it to bring the app to
68+
* a steady state (initial render done, data loaded, …). Not measured.
69+
*/
70+
setup?: BenchHook;
71+
/**
72+
* Run after each round, before the app is closed. Use it for teardown that
73+
* should not be measured.
74+
*/
75+
teardown?: BenchHook;
76+
}
77+
78+
let integrationInitialized = false;
79+
80+
/**
81+
* Register the integration and environment with the instrumentation. This is
82+
* process-global, so it only needs to run once regardless of how many
83+
* benchmarks are defined.
84+
*/
85+
function ensureIntegrationSetup(): void {
86+
if (integrationInitialized) return;
87+
integrationInitialized = true;
88+
89+
InstrumentHooks.setIntegration("node-custom", __VERSION__);
90+
InstrumentHooks.setEnvironment("nodejs", "version", process.versions.node);
91+
InstrumentHooks.setEnvironment("nodejs", "v8", process.versions.v8);
92+
InstrumentHooks.writeEnvironment(process.pid);
93+
}
94+
95+
function resolveRounds(optionRounds: number | undefined): number {
96+
const envValue = process.env.CODSPEED_ROUNDS;
97+
const raw = envValue ?? optionRounds;
98+
if (raw === undefined) return DEFAULT_ROUNDS;
99+
const n = Number(raw);
100+
if (!Number.isInteger(n) || n < 1) {
101+
throw new Error(`Invalid rounds value: ${raw} (expected positive integer)`);
102+
}
103+
return n;
104+
}
105+
106+
/**
107+
* Resolve the path to the Electron binary.
108+
*
109+
* Playwright resolves Electron via `require("electron/index.js")` from inside
110+
* its own package directory. Under isolated installs (e.g. pnpm), Playwright
111+
* cannot see the project's `electron` dependency and bails out with
112+
* "Electron executablePath not found!". We resolve it ourselves from the
113+
* benchmark's working directory, where `electron` is a real dependency.
114+
*/
115+
function resolveElectronExecutable(cwd: string): string {
116+
const require = createRequire(pathToFileURL(`${cwd}/`));
117+
// `electron`'s main module exports the absolute path to its binary.
118+
return require("electron") as string;
119+
}
120+
121+
async function launchApp(options: BenchOptions): Promise<ElectronApplication> {
122+
const cwd = options.cwd ?? process.cwd();
123+
return electron.launch({
124+
args: [
125+
options.appPath,
126+
...(options.electronArgs ?? []),
127+
`--js-flags=${DEFAULT_PROFILING_JS_FLAGS}`,
128+
],
129+
cwd,
130+
executablePath:
131+
options.electronExecutablePath ?? resolveElectronExecutable(cwd),
132+
});
133+
}
134+
135+
async function runOneSample(
136+
fn: BenchFunction,
137+
options: BenchOptions,
138+
): Promise<bigint> {
139+
const app = await launchApp(options);
140+
const page = await app.firstWindow();
141+
142+
try {
143+
if (options.setup) {
144+
await options.setup({ page });
145+
}
146+
147+
const startTs = InstrumentHooks.currentTimestamp();
148+
await fn({ page });
149+
const endTs = InstrumentHooks.currentTimestamp();
150+
151+
InstrumentHooks.addMarker(
152+
process.pid,
153+
MARKER_TYPE_BENCHMARK_START,
154+
startTs,
155+
);
156+
InstrumentHooks.addMarker(process.pid, MARKER_TYPE_BENCHMARK_END, endTs);
157+
158+
if (options.teardown) {
159+
await options.teardown({ page });
160+
}
161+
162+
return endTs - startTs;
163+
} finally {
164+
await app.close();
165+
}
166+
}
167+
168+
function buildStats(sampleTimesNs: bigint[]): BenchmarkStats {
169+
const sortedTimesNs = sampleTimesNs
170+
.map((n) => Number(n))
171+
.sort((a, b) => a - b);
172+
173+
const sum = sortedTimesNs.reduce((acc, t) => acc + t, 0);
174+
const meanNs = sum / sortedTimesNs.length;
175+
const variance =
176+
sortedTimesNs.reduce((acc, t) => acc + (t - meanNs) ** 2, 0) /
177+
sortedTimesNs.length;
178+
const stdevNs = Math.sqrt(variance);
179+
180+
const { q1_ns, median_ns, q3_ns, iqr_outlier_rounds, stdev_outlier_rounds } =
181+
calculateQuantiles({
182+
meanNs,
183+
stdevNs,
184+
sortedTimesNs,
185+
});
186+
187+
return {
188+
min_ns: sortedTimesNs[0],
189+
max_ns: sortedTimesNs[sortedTimesNs.length - 1],
190+
mean_ns: meanNs,
191+
stdev_ns: stdevNs,
192+
q1_ns,
193+
median_ns,
194+
q3_ns,
195+
rounds: sortedTimesNs.length,
196+
total_time: msToS(sum / 1e6),
197+
iqr_outlier_rounds,
198+
stdev_outlier_rounds,
199+
iter_per_round: 1,
200+
warmup_iters: 0,
201+
};
202+
}
203+
204+
/**
205+
* Define and run a CodSpeed-instrumented Electron benchmark, mirroring
206+
* Playwright's `test` API.
207+
*
208+
* Launches the Electron app once per round, runs the user-provided function
209+
* around a measured region, and writes walltime results to disk so that the
210+
* CodSpeed runner can pick them up.
211+
*
212+
* @example
213+
* ```ts
214+
* import { bench } from "@codspeed/playwright";
215+
*
216+
* bench(
217+
* "renders the dashboard",
218+
* async ({ page }) => {
219+
* await page.getByRole("link", { name: "Dashboard" }).click();
220+
* await page.getByRole("heading", { name: "Overview" }).waitFor();
221+
* },
222+
* { appPath: "out/main/index.js", rounds: 5 },
223+
* );
224+
* ```
225+
*/
226+
export async function bench(
227+
name: string,
228+
fn: BenchFunction,
229+
options: BenchOptions,
230+
): Promise<void> {
231+
const rounds = resolveRounds(options.rounds);
232+
const uri = `${getCallingFile(0)}::${name}`;
233+
234+
ensureIntegrationSetup();
235+
236+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
237+
InstrumentHooks.startBenchmark();
238+
239+
const sampleTimesNs: bigint[] = [];
240+
for (let i = 0; i < rounds; i++) {
241+
const elapsedNs = await runOneSample(fn, options);
242+
sampleTimesNs.push(elapsedNs);
243+
console.log(
244+
`[CodSpeed] [round ${i + 1}/${rounds}] ${(Number(elapsedNs) / 1e6).toFixed(2)} ms`,
245+
);
246+
}
247+
248+
InstrumentHooks.stopBenchmark();
249+
250+
const benchmark: Benchmark = {
251+
name,
252+
uri,
253+
config: {
254+
warmup_time_ns: null,
255+
min_round_time_ns: null,
256+
max_rounds: rounds,
257+
max_time_ns: null,
258+
},
259+
stats: buildStats(sampleTimesNs),
260+
};
261+
262+
writeWalltimeResults([benchmark]);
263+
}
264+
265+
export type { Page } from "playwright-core";

packages/playwright/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src",
6+
"typeRoots": ["node_modules/@types", "../../node_modules/@types"]
7+
},
8+
"references": [{ "path": "../core" }],
9+
"include": ["src/**/*.ts"]
10+
}

0 commit comments

Comments
 (0)