Skip to content

Commit 24f9e5b

Browse files
feat: add @codspeed/electron package to allow users to bench electron apps
1 parent d23b1dd commit 24f9e5b

5 files changed

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