Skip to content

Commit c4b5a92

Browse files
feat: add @codspeed/electron package to allow users to bench electron apps
1 parent d835b23 commit c4b5a92

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

packages/electron/package.json

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

packages/electron/rollup.config.ts

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/electron/src/index.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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";
20+
21+
export type ElectronBenchmarkHook = (window: Page) => void | Promise<void>;
22+
23+
export interface ElectronBenchmarkOptions {
24+
/**
25+
* The benchmark name.
26+
*/
27+
name: string;
28+
/**
29+
* Absolute path to the Electron main entrypoint (e.g. `out/main/index.js`).
30+
*/
31+
appPath: string;
32+
/**
33+
* Additional CLI flags forwarded to Electron.
34+
*/
35+
electronArgs?: string[];
36+
/**
37+
* Working directory for the Electron process. Defaults to `process.cwd()`.
38+
*/
39+
cwd?: string;
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+
* Called once after the window opens, before any measurement. Use this to
47+
* wait for the application to reach a steady state (initial render done,
48+
* data loaded, …).
49+
*/
50+
beforeMeasurement?: ElectronBenchmarkHook;
51+
/**
52+
* The actually-measured workload. Everything that runs inside this callback
53+
* is included in the reported timing.
54+
*/
55+
measurement: ElectronBenchmarkHook;
56+
/**
57+
* Called once after measurement, before the app is closed. Use this for
58+
* teardown that should not be measured.
59+
*/
60+
afterMeasurement?: ElectronBenchmarkHook;
61+
}
62+
63+
function resolveRounds(optionRounds: number | undefined): number {
64+
const envValue = process.env.CODSPEED_ROUNDS;
65+
const raw = envValue ?? optionRounds;
66+
if (raw === undefined) return DEFAULT_ROUNDS;
67+
const n = Number(raw);
68+
if (!Number.isInteger(n) || n < 1) {
69+
throw new Error(`Invalid rounds value: ${raw} (expected positive integer)`);
70+
}
71+
return n;
72+
}
73+
74+
async function launchApp(
75+
appPath: string,
76+
electronArgs: string[],
77+
cwd: string,
78+
): Promise<ElectronApplication> {
79+
return electron.launch({
80+
args: [
81+
appPath,
82+
...electronArgs,
83+
`--js-flags=${DEFAULT_PROFILING_JS_FLAGS}`,
84+
],
85+
cwd,
86+
});
87+
}
88+
89+
async function runOneSample(
90+
options: ElectronBenchmarkOptions,
91+
): Promise<bigint> {
92+
const app = await launchApp(
93+
options.appPath,
94+
options.electronArgs ?? [],
95+
options.cwd ?? process.cwd(),
96+
);
97+
const win = await app.firstWindow();
98+
99+
try {
100+
if (options.beforeMeasurement) {
101+
await options.beforeMeasurement(win);
102+
}
103+
104+
const startTs = InstrumentHooks.currentTimestamp();
105+
await options.measurement(win);
106+
const endTs = InstrumentHooks.currentTimestamp();
107+
108+
InstrumentHooks.addMarker(
109+
process.pid,
110+
MARKER_TYPE_BENCHMARK_START,
111+
startTs,
112+
);
113+
InstrumentHooks.addMarker(process.pid, MARKER_TYPE_BENCHMARK_END, endTs);
114+
115+
if (options.afterMeasurement) {
116+
await options.afterMeasurement(win);
117+
}
118+
119+
return endTs - startTs;
120+
} finally {
121+
await app.close();
122+
}
123+
}
124+
125+
function buildStats(sampleTimesNs: bigint[]): BenchmarkStats {
126+
const sortedTimesNs = sampleTimesNs
127+
.map((n) => Number(n))
128+
.sort((a, b) => a - b);
129+
130+
const sum = sortedTimesNs.reduce((acc, t) => acc + t, 0);
131+
const meanNs = sum / sortedTimesNs.length;
132+
const variance =
133+
sortedTimesNs.reduce((acc, t) => acc + (t - meanNs) ** 2, 0) /
134+
sortedTimesNs.length;
135+
const stdevNs = Math.sqrt(variance);
136+
137+
const { q1_ns, median_ns, q3_ns, iqr_outlier_rounds, stdev_outlier_rounds } =
138+
calculateQuantiles({
139+
meanNs,
140+
stdevNs,
141+
sortedTimesNs,
142+
});
143+
144+
return {
145+
min_ns: sortedTimesNs[0],
146+
max_ns: sortedTimesNs[sortedTimesNs.length - 1],
147+
mean_ns: meanNs,
148+
stdev_ns: stdevNs,
149+
q1_ns,
150+
median_ns,
151+
q3_ns,
152+
rounds: sortedTimesNs.length,
153+
total_time: msToS(sum / 1e6),
154+
iqr_outlier_rounds,
155+
stdev_outlier_rounds,
156+
iter_per_round: 1,
157+
warmup_iters: 0,
158+
};
159+
}
160+
161+
/**
162+
* Run a CodSpeed-instrumented Electron benchmark.
163+
*
164+
* Launches the Electron app once per round, runs the user-provided hooks
165+
* around a measured region, and writes walltime results to disk so that the
166+
* CodSpeed runner can pick them up.
167+
*/
168+
export async function runElectronBenchmark(
169+
options: ElectronBenchmarkOptions,
170+
): Promise<void> {
171+
const rounds = resolveRounds(options.rounds);
172+
const uri = `${getCallingFile(1)}::${options.name}`;
173+
174+
InstrumentHooks.setIntegration("node-custom", __VERSION__);
175+
InstrumentHooks.setEnvironment("nodejs", "version", process.versions.node);
176+
InstrumentHooks.setEnvironment("nodejs", "v8", process.versions.v8);
177+
InstrumentHooks.writeEnvironment(process.pid);
178+
179+
InstrumentHooks.setExecutedBenchmark(process.pid, uri);
180+
InstrumentHooks.startBenchmark();
181+
182+
const sampleTimesNs: bigint[] = [];
183+
for (let i = 0; i < rounds; i++) {
184+
const elapsedNs = await runOneSample(options);
185+
sampleTimesNs.push(elapsedNs);
186+
console.log(
187+
`[CodSpeed] [round ${i + 1}/${rounds}] ${(Number(elapsedNs) / 1e6).toFixed(2)} ms`,
188+
);
189+
}
190+
191+
InstrumentHooks.stopBenchmark();
192+
193+
const benchmark: Benchmark = {
194+
name: options.name,
195+
uri,
196+
config: {
197+
warmup_time_ns: null,
198+
min_round_time_ns: null,
199+
max_rounds: rounds,
200+
max_time_ns: null,
201+
},
202+
stats: buildStats(sampleTimesNs),
203+
};
204+
205+
writeWalltimeResults([benchmark]);
206+
}
207+
208+
export type { Page } from "playwright-core";

packages/electron/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+
}

pnpm-lock.yaml

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)