Skip to content

Commit 260ad3c

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 f5c5e99 commit 260ad3c

5 files changed

Lines changed: 353 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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 { _electron as electron } from "playwright";
13+
import type { ElectronApplication, Page } from "playwright-core";
14+
15+
declare const __VERSION__: string;
16+
17+
const DEFAULT_ROUNDS = 1;
18+
const DEFAULT_PROFILING_JS_FLAGS =
19+
"--perf-prof --perf-prof-annotate-wasm --interpreted-frames-native-stack --no-turbo-inlining --no-sandbox";
20+
21+
/**
22+
* The fixtures passed to a benchmark function, mirroring Playwright's `test`
23+
* API. Currently exposes the Electron window as `page`.
24+
*/
25+
export interface BenchFixtures {
26+
page: Page;
27+
}
28+
29+
/**
30+
* The function whose execution is measured. Everything that runs inside it is
31+
* included in the reported timing.
32+
*/
33+
export type BenchFunction = (fixtures: BenchFixtures) => void | Promise<void>;
34+
35+
/**
36+
* Minimal options for a benchmark. Inspired by Vitest's `bench`, but kept
37+
* deliberately small.
38+
*/
39+
export interface BenchOptions {
40+
/**
41+
* Number of measurement rounds to perform. Defaults to 1, can be overridden
42+
* via the `CODSPEED_ROUNDS` environment variable.
43+
*/
44+
rounds?: number;
45+
/**
46+
* Absolute path to the Electron main entrypoint (e.g. `out/main/index.js`).
47+
*/
48+
appPath: string;
49+
/**
50+
* Additional CLI flags forwarded to Electron.
51+
*/
52+
electronArgs?: string[];
53+
/**
54+
* Working directory for the Electron process. Defaults to `process.cwd()`.
55+
*/
56+
cwd?: string;
57+
/**
58+
* Absolute path to the Electron executable. When omitted, Playwright resolves
59+
* it via `require("electron")`. Set this when running under package managers
60+
* (e.g. pnpm) where Playwright cannot resolve `electron` from its own
61+
* directory.
62+
*/
63+
executablePath?: string;
64+
}
65+
66+
function resolveRounds(optionRounds: number | undefined): number {
67+
const envValue = process.env.CODSPEED_ROUNDS;
68+
const raw = envValue ?? optionRounds;
69+
if (raw === undefined) return DEFAULT_ROUNDS;
70+
const n = Number(raw);
71+
if (!Number.isInteger(n) || n < 1) {
72+
throw new Error(`Invalid rounds value: ${raw} (expected positive integer)`);
73+
}
74+
return n;
75+
}
76+
77+
async function launchApp(options: BenchOptions): Promise<ElectronApplication> {
78+
return electron.launch({
79+
args: [
80+
options.appPath,
81+
...(options.electronArgs ?? []),
82+
`--js-flags=${DEFAULT_PROFILING_JS_FLAGS}`,
83+
],
84+
cwd: options.cwd ?? process.cwd(),
85+
executablePath: options.executablePath,
86+
});
87+
}
88+
89+
async function runOneSample(
90+
fn: BenchFunction,
91+
options: BenchOptions,
92+
): Promise<bigint> {
93+
const app = await launchApp(options);
94+
const page = await app.firstWindow();
95+
96+
try {
97+
const startTs = InstrumentHooks.currentTimestamp();
98+
await fn({ page });
99+
const endTs = InstrumentHooks.currentTimestamp();
100+
101+
InstrumentHooks.addMarker(
102+
process.pid,
103+
MARKER_TYPE_BENCHMARK_START,
104+
startTs,
105+
);
106+
InstrumentHooks.addMarker(process.pid, MARKER_TYPE_BENCHMARK_END, endTs);
107+
108+
return endTs - startTs;
109+
} finally {
110+
await app.close();
111+
}
112+
}
113+
114+
function buildStats(sampleTimesNs: bigint[]): BenchmarkStats {
115+
const sortedTimesNs = sampleTimesNs
116+
.map((n) => Number(n))
117+
.sort((a, b) => a - b);
118+
119+
const sum = sortedTimesNs.reduce((acc, t) => acc + t, 0);
120+
const meanNs = sum / sortedTimesNs.length;
121+
const variance =
122+
sortedTimesNs.reduce((acc, t) => acc + (t - meanNs) ** 2, 0) /
123+
sortedTimesNs.length;
124+
const stdevNs = Math.sqrt(variance);
125+
126+
const { q1_ns, median_ns, q3_ns, iqr_outlier_rounds, stdev_outlier_rounds } =
127+
calculateQuantiles({
128+
meanNs,
129+
stdevNs,
130+
sortedTimesNs,
131+
});
132+
133+
return {
134+
min_ns: sortedTimesNs[0],
135+
max_ns: sortedTimesNs[sortedTimesNs.length - 1],
136+
mean_ns: meanNs,
137+
stdev_ns: stdevNs,
138+
q1_ns,
139+
median_ns,
140+
q3_ns,
141+
rounds: sortedTimesNs.length,
142+
total_time: msToS(sum / 1e6),
143+
iqr_outlier_rounds,
144+
stdev_outlier_rounds,
145+
iter_per_round: 1,
146+
warmup_iters: 0,
147+
};
148+
}
149+
150+
/**
151+
* Define and run a CodSpeed-instrumented Electron benchmark, mirroring
152+
* Playwright's `test` API.
153+
*
154+
* Launches the Electron app once per round, runs the user-provided function
155+
* around a measured region, and writes walltime results to disk so that the
156+
* CodSpeed runner can pick them up.
157+
*
158+
* @example
159+
* ```ts
160+
* import { bench } from "@codspeed/playwright";
161+
*
162+
* bench(
163+
* "renders the dashboard",
164+
* async ({ page }) => {
165+
* await page.getByRole("link", { name: "Dashboard" }).click();
166+
* await page.getByRole("heading", { name: "Overview" }).waitFor();
167+
* },
168+
* { appPath: "out/main/index.js", rounds: 5 },
169+
* );
170+
* ```
171+
*/
172+
export async function bench(
173+
name: string,
174+
fn: BenchFunction,
175+
options: BenchOptions,
176+
): Promise<void> {
177+
const rounds = resolveRounds(options.rounds);
178+
const uri = `${getCallingFile(0)}::${name}`;
179+
180+
InstrumentHooks.setIntegration("node-custom", __VERSION__);
181+
InstrumentHooks.setEnvironment("nodejs", "version", process.versions.node);
182+
InstrumentHooks.setEnvironment("nodejs", "v8", process.versions.v8);
183+
InstrumentHooks.writeEnvironment(process.pid);
184+
185+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
186+
InstrumentHooks.startBenchmark();
187+
188+
const sampleTimesNs: bigint[] = [];
189+
for (let i = 0; i < rounds; i++) {
190+
const elapsedNs = await runOneSample(fn, options);
191+
sampleTimesNs.push(elapsedNs);
192+
console.log(
193+
`[CodSpeed] [round ${i + 1}/${rounds}] ${(Number(elapsedNs) / 1e6).toFixed(2)} ms`,
194+
);
195+
}
196+
197+
InstrumentHooks.stopBenchmark();
198+
199+
const benchmark: Benchmark = {
200+
name,
201+
uri,
202+
config: {
203+
warmup_time_ns: null,
204+
min_round_time_ns: null,
205+
max_rounds: rounds,
206+
max_time_ns: null,
207+
},
208+
stats: buildStats(sampleTimesNs),
209+
};
210+
211+
writeWalltimeResults([benchmark]);
212+
}
213+
214+
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)