webmcp-gauge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The exit-code contract, end to end through the CLI.
3
+ *
4
+ * These tests drive the real binary, with a pre-seeded checkpoint and `--port 1`
5
+ * so no browser is launched and no judge is called: every planned trial is already
6
+ * in the checkpoint, so `--resume` has nothing to run. That makes the contract a
7
+ * CI job depends on - 0 pass, 1 breach, 2 unmeasurable - testable in milliseconds
8
+ * and without a provider, which is the only reason it can be a test at all rather
9
+ * than a paragraph in a log entry.
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { spawn } from 'node:child_process';
14
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { buildPlan } from '../core/sweep.mjs';
19
+
20
+ const binPath = fileURLToPath(new URL('./webmcp-gauge.mjs', import.meta.url));
21
+ const fixture = JSON.parse(
22
+ await readFile(new URL('../fixtures/airlock.utterances.json', import.meta.url), 'utf8')
23
+ );
24
+
25
+ const GATED_TOOL = 'top_expenses';
26
+
27
+ /**
28
+ * Synthesises the checkpoint a finished run would have left, from the same plan
29
+ * builder the sweep uses - so a fixture change moves the expectation with it
30
+ * instead of leaving a stale hand-written id list behind.
31
+ */
32
+ const seedCheckpoint = async ({ dir, ok, drop = 0 }) => {
33
+ const plan = buildPlan({
34
+ fixture,
35
+ repeatsPerSession: 1,
36
+ tools: [GATED_TOOL],
37
+ includeControls: false,
38
+ });
39
+ const kept = plan.slice(0, plan.length - drop);
40
+ const lines = kept.map((item, index) =>
41
+ JSON.stringify({
42
+ session: 1,
43
+ repeat: item.repeat,
44
+ kind: item.kind,
45
+ utteranceId: item.utterance.id,
46
+ tag: item.utterance.tag ?? null,
47
+ expectedTool: item.toolName,
48
+ outcome: index < ok ? 'ok' : 'wrong_tool',
49
+ reason: 'synthetic record: exit-code contract test, no judge involved',
50
+ selection: { tool: index < ok ? item.toolName : 'describe_dataset', arguments: {} },
51
+ })
52
+ );
53
+ await writeFile(join(dir, 'sweep.jsonl'), `${lines.join('\n')}\n`, 'utf8');
54
+ return { planned: plan.length, seeded: kept.length };
55
+ };
56
+
57
+ const runCli = (dir, extra = []) =>
58
+ new Promise((resolve) => {
59
+ const child = spawn(
60
+ process.execPath,
61
+ [
62
+ binPath,
63
+ 'run',
64
+ '--resume',
65
+ // Port 1 answers nothing, which is deliberate: nothing should need it.
66
+ '--port',
67
+ '1',
68
+ '--sessions',
69
+ '1',
70
+ '--repeats',
71
+ '1',
72
+ '--tools',
73
+ GATED_TOOL,
74
+ '--no-controls',
75
+ '--judge',
76
+ 'exit-code-contract-judge',
77
+ '--base-url',
78
+ 'https://judge.invalid/v1',
79
+ '--out',
80
+ dir,
81
+ ...extra,
82
+ ],
83
+ {
84
+ env: { ...process.env, WEBMCP_GAUGE_JUDGE_API_KEY: 'never-sent-anywhere' },
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ }
87
+ );
88
+
89
+ let stdout = '';
90
+ let stderr = '';
91
+ child.stdout.on('data', (chunk) => {
92
+ stdout += chunk;
93
+ });
94
+ child.stderr.on('data', (chunk) => {
95
+ stderr += chunk;
96
+ });
97
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
98
+ });
99
+
100
+ /**
101
+ * The static lint path needs no browser, no judge and no key, which is the claim
102
+ * being tested as much as the exit code: L0 is the free on-ramp, so no judge
103
+ * variable is passed here at all.
104
+ */
105
+ const runLint = (extra = []) =>
106
+ new Promise((resolve) => {
107
+ const child = spawn(
108
+ process.execPath,
109
+ [binPath, 'lint', '--manifest', fileURLToPath(new URL('../fixtures/broken/tools.json', import.meta.url)), ...extra],
110
+ {
111
+ env: {
112
+ ...process.env,
113
+ WEBMCP_GAUGE_JUDGE_MODEL: '',
114
+ WEBMCP_GAUGE_JUDGE_BASE_URL: '',
115
+ WEBMCP_GAUGE_JUDGE_API_KEY: '',
116
+ },
117
+ stdio: ['ignore', 'pipe', 'pipe'],
118
+ }
119
+ );
120
+
121
+ let stdout = '';
122
+ let stderr = '';
123
+ child.stdout.on('data', (chunk) => {
124
+ stdout += chunk;
125
+ });
126
+ child.stderr.on('data', (chunk) => {
127
+ stderr += chunk;
128
+ });
129
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
130
+ });
131
+
132
+ const withTempRun = async (body) => {
133
+ const dir = await mkdtemp(join(tmpdir(), 'webmcp-gauge-exit-'));
134
+ try {
135
+ return await body(dir);
136
+ } finally {
137
+ await rm(dir, { recursive: true, force: true });
138
+ }
139
+ };
140
+
141
+ const readReport = async (dir) => JSON.parse(await readFile(join(dir, 'report.json'), 'utf8'));
142
+ const readBadge = async (dir) => JSON.parse(await readFile(join(dir, 'badge.json'), 'utf8'));
143
+
144
+ test('a complete run above the threshold exits 0', async () => {
145
+ await withTempRun(async (dir) => {
146
+ const { planned, seeded } = await seedCheckpoint({ dir, ok: 20 });
147
+ assert.equal(seeded, planned, 'this case is only meaningful with the full plan measured');
148
+
149
+ const result = await runCli(dir, ['--fail-under', '0.9']);
150
+
151
+ assert.equal(result.code, 0, result.stderr);
152
+ assert.match(result.stderr, /gate: PASS —/);
153
+
154
+ const report = await readReport(dir);
155
+ assert.equal(report.gate.code, 0);
156
+ assert.equal(report.coverage.missingTrials, 0);
157
+ assert.equal(report.coverage.expectedTrials, planned);
158
+ });
159
+ });
160
+
161
+ /**
162
+ * The badge is written by the same command that writes the report, so these two
163
+ * cases check the wiring rather than the formatting (report/badge.test.mjs owns
164
+ * that): a complete run gets a rate, and an incomplete one must not.
165
+ */
166
+ test('a complete run leaves a badge carrying the rate and the trial count', async () => {
167
+ await withTempRun(async (dir) => {
168
+ const { planned } = await seedCheckpoint({ dir, ok: 20 });
169
+
170
+ const result = await runCli(dir, ['--badge-label', 'airlock']);
171
+
172
+ assert.equal(result.code, 0, result.stderr);
173
+ const badge = await readBadge(dir);
174
+ assert.equal(badge.label, 'airlock');
175
+ assert.equal(badge.message, `100% (n=${planned})`);
176
+ assert.equal(badge.color, 'brightgreen');
177
+
178
+ const svg = await readFile(join(dir, 'badge.svg'), 'utf8');
179
+ assert.match(svg, /<svg/);
180
+ assert.match(svg, new RegExp(`100% \\(n=${planned}\\)`));
181
+ });
182
+ });
183
+
184
+ test('an incomplete run leaves a badge that says incomplete, not a rate', async () => {
185
+ await withTempRun(async (dir) => {
186
+ const { planned } = await seedCheckpoint({ dir, ok: 12, drop: 8 });
187
+
188
+ const result = await runCli(dir);
189
+
190
+ assert.equal(result.code, 2, result.stderr);
191
+ const badge = await readBadge(dir);
192
+ assert.equal(badge.message, `incomplete (${planned - 8}/${planned})`);
193
+ assert.equal(badge.isError, true);
194
+ assert.ok(!/%/.test(badge.message), 'an unmeasured run must not publish a percentage');
195
+ });
196
+ });
197
+
198
+ /**
199
+ * Found by CI, not by reasoning: Chrome failed to start on a runner, the CLI's
200
+ * top-level await turned it into an unhandled rejection, Node exited 1, and the
201
+ * Action announced "the manifest has findings" for a lint that never ran. A broken
202
+ * environment must never be reportable as a bad page.
203
+ */
204
+ test('a browser that cannot start is exit 2, not a threshold breach', async () => {
205
+ const root = fileURLToPath(new URL('..', import.meta.url));
206
+ const result = await new Promise((resolve) => {
207
+ const child = spawn(
208
+ process.execPath,
209
+ [binPath, 'lint', '--serve', 'fixtures/broken', '--url', 'twin.html?variant=clean'],
210
+ {
211
+ cwd: root,
212
+ env: {
213
+ ...process.env,
214
+ // A path no browser lives at. Everything else about the invocation is
215
+ // exactly what CI runs.
216
+ WEBMCP_GAUGE_CHROME: join(root, 'no-such-chrome-binary'),
217
+ WEBMCP_GAUGE_LAUNCH_TIMEOUT_MS: '2000',
218
+ },
219
+ }
220
+ );
221
+ let stderr = '';
222
+ child.stderr.on('data', (chunk) => {
223
+ stderr += chunk;
224
+ });
225
+ child.on('close', (code) => resolve({ code, stderr }));
226
+ });
227
+
228
+ assert.equal(result.code, 2, `expected "could not measure", got ${result.code}: ${result.stderr}`);
229
+ assert.match(result.stderr, /could not measure/);
230
+ });
231
+
232
+ test('a complete run with a rate below --fail-under exits 1', async () => {
233
+ await withTempRun(async (dir) => {
234
+ await seedCheckpoint({ dir, ok: 16 });
235
+
236
+ const result = await runCli(dir, ['--fail-under', '0.9']);
237
+
238
+ assert.equal(result.code, 1, result.stderr);
239
+ assert.match(result.stderr, /gate: FAIL —/);
240
+ assert.match(result.stderr, new RegExp(`\`${GATED_TOOL}\` 80\\.0%`));
241
+
242
+ const report = await readReport(dir);
243
+ assert.equal(report.gate.status, 'breach');
244
+ assert.equal(report.gate.breaches[0].tool, GATED_TOOL);
245
+ assert.equal(report.coverage.missingTrials, 0);
246
+ });
247
+ });
248
+
249
+ test('trials that could not be measured exit 2, not 1', async () => {
250
+ await withTempRun(async (dir) => {
251
+ // Three trials are missing from the checkpoint, so the resumed session tries to
252
+ // run them and cannot: port 1 has no browser. That is the real unmeasurable
253
+ // path, not a simulated one.
254
+ await seedCheckpoint({ dir, ok: 17, drop: 3 });
255
+
256
+ const result = await runCli(dir, ['--fail-under', '0.9']);
257
+
258
+ assert.equal(result.code, 2, result.stderr);
259
+ assert.match(result.stderr, /gate: INCOMPLETE —/);
260
+ assert.match(result.stderr, /3 of 20 planned trials produced no measurement/);
261
+ assert.match(result.stderr, /trial_threw/);
262
+ assert.match(result.stderr, /not a threshold breach/);
263
+
264
+ const report = await readReport(dir);
265
+ assert.equal(report.gate.code, 2);
266
+ assert.equal(report.coverage.missingTrials, 3);
267
+ assert.equal(report.harnessFailures.length, 3);
268
+
269
+ // One line per failed trial, written during the session rather than at the end
270
+ // of it. A double-append would show six, and an end-of-session append would
271
+ // lose all three if the process were killed first.
272
+ const log = await readFile(join(dir, 'harness-failures.jsonl'), 'utf8');
273
+ assert.equal(log.trim().split('\n').length, 3);
274
+ });
275
+ });
276
+
277
+ test('an incomplete run exits 2 even with no threshold, where it used to exit 1', async () => {
278
+ await withTempRun(async (dir) => {
279
+ await seedCheckpoint({ dir, ok: 19, drop: 1 });
280
+
281
+ const result = await runCli(dir);
282
+
283
+ assert.equal(result.code, 2, result.stderr);
284
+ assert.equal((await readReport(dir)).gate.failUnder, null);
285
+ });
286
+ });
287
+
288
+ test('a complete run with no threshold exits 0 and stamps the verdict into the artifact', async () => {
289
+ await withTempRun(async (dir) => {
290
+ await seedCheckpoint({ dir, ok: 12 });
291
+
292
+ const result = await runCli(dir);
293
+
294
+ assert.equal(result.code, 0, result.stderr);
295
+ assert.match(result.stdout, /\*\*Gate:\*\* COMPLETE —/);
296
+
297
+ const report = await readReport(dir);
298
+ assert.equal(report.schema, 'webmcp-gauge/report/3');
299
+ assert.equal(report.gate.status, 'pass');
300
+ assert.equal(report.gate.gatedTools, 0);
301
+ });
302
+ });
303
+
304
+ test('--fail-under 90 is refused rather than gating every build against 9000%', async () => {
305
+ await withTempRun(async (dir) => {
306
+ await seedCheckpoint({ dir, ok: 20 });
307
+
308
+ const result = await runCli(dir, ['--fail-under', '90']);
309
+
310
+ assert.equal(result.code, 2);
311
+ assert.match(result.stderr, /use 0\.9, not 90/);
312
+ });
313
+ });
314
+
315
+ test('lint runs with no judge configured at all and exits 0 on a clean manifest', async () => {
316
+ const result = await runLint(['--variant', 'clean']);
317
+
318
+ assert.equal(result.code, 0, result.stderr);
319
+ assert.match(result.stdout, /No findings/);
320
+ });
321
+
322
+ test('lint exits 1 on a manifest with error-level findings, and says it is not a measured rate', async () => {
323
+ const result = await runLint(['--variant', 'degraded']);
324
+
325
+ assert.equal(result.code, 1, result.stderr);
326
+ assert.match(result.stdout, /ERROR\s+name\/invalid-characters/);
327
+ assert.match(result.stderr, /not a measured invocation rate/);
328
+ });
329
+
330
+ test('--fail-on warning promotes advice to a failure, and the default does not', async () => {
331
+ // The clean manifest carries no findings at all until the description floor is
332
+ // raised past what the reference page ships, which then makes every description
333
+ // thin: warnings only, so the two --fail-on levels are separable on one input.
334
+ const advisory = await runLint(['--variant', 'clean', '--min-description', '400']);
335
+ assert.equal(advisory.code, 0, 'warnings alone must not fail a build by default');
336
+ assert.match(advisory.stdout, /WARN\s+description\/thin/);
337
+
338
+ const strict = await runLint(['--variant', 'clean', '--min-description', '400', '--fail-on', 'warning']);
339
+ assert.equal(strict.code, 1, strict.stderr);
340
+ });
341
+
342
+ test('lint exits 2 when the manifest cannot be read as one', async () => {
343
+ const result = await runLint(['--variant', 'no-such-variant']);
344
+
345
+ assert.equal(result.code, 2);
346
+ assert.match(result.stderr, /has no variants\.no-such-variant array/);
347
+ });
348
+
349
+ test('lint refuses a --fail-on level it does not implement rather than guessing', async () => {
350
+ const result = await runLint(['--variant', 'clean', '--fail-on', 'nit']);
351
+
352
+ assert.equal(result.code, 2);
353
+ assert.match(result.stderr, /takes 'error' or 'warning'/);
354
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Launches and tears down one flagged Chrome per measurement session.
3
+ *
4
+ * This exists because of what the two 1.2.0/1.3.0 sweeps showed: repeats that
5
+ * share a browser process, a warm page cache and one provider session are
6
+ * correlated by construction, so the sigma computed across them describes session
7
+ * stability rather than run-to-run stability. A session needs its own browser and
8
+ * its own cold cache before its variance means anything.
9
+ *
10
+ * Measured 2026-08-30: a brand-new user-data-dir containing nothing but
11
+ * {"browser":{"enabled_labs_experiments":["enable-webmcp-testing@1"]}} in
12
+ * `Local State` is enough for Chrome 152 to expose document.modelContext, and it
13
+ * works in --headless=new. No copied profile, no flag UI, and a genuinely cold
14
+ * cache per session.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
18
+ import { createServer } from 'node:net';
19
+ import { resolve } from 'node:path';
20
+
21
+ const DEFAULT_CHROME =
22
+ process.env.WEBMCP_GAUGE_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
23
+
24
+ const WEBMCP_FLAG_PROFILE = JSON.stringify({
25
+ browser: { enabled_labs_experiments: ['enable-webmcp-testing@1'] },
26
+ });
27
+
28
+ export const findFreePort = () =>
29
+ new Promise((resolve, reject) => {
30
+ const server = createServer();
31
+ server.unref();
32
+ server.on('error', reject);
33
+ server.listen(0, '127.0.0.1', () => {
34
+ const { port } = server.address();
35
+ server.close(() => resolve(port));
36
+ });
37
+ });
38
+
39
+ const waitForDevTools = async (port, { timeoutMs = 30000, pollTimeoutMs = 2000 } = {}) => {
40
+ const deadline = Date.now() + timeoutMs;
41
+ let lastError = 'never answered';
42
+
43
+ while (Date.now() < deadline) {
44
+ try {
45
+ // Each poll is bounded on its own. A deadline around an unbounded fetch is
46
+ // not a deadline: one request that never answers holds the loop open past it
47
+ // forever, which is how a launch turns into a silent hang instead of an error.
48
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
49
+ signal: AbortSignal.timeout(Math.min(pollTimeoutMs, Math.max(250, deadline - Date.now()))),
50
+ });
51
+ if (response.ok) {
52
+ const payload = await response.json();
53
+ return { build: payload.Browser, protocol: payload['Protocol-Version'] };
54
+ }
55
+ lastError = `HTTP ${response.status}`;
56
+ } catch (error) {
57
+ lastError = String(error.message ?? error);
58
+ }
59
+ await new Promise((resolve) => setTimeout(resolve, 250));
60
+ }
61
+
62
+ throw new Error(`Chrome did not expose DevTools on ${port} within ${timeoutMs}ms (${lastError})`);
63
+ };
64
+
65
+ /**
66
+ * Windows leaves Chrome's renderer and GPU children alive when only the parent is
67
+ * killed, and those children keep the profile directory locked, which then fails
68
+ * the cleanup and silently reuses a warm profile next session.
69
+ *
70
+ * Exported because the orchestrator needs the same thing for a session process:
71
+ * killing the node child alone would orphan the Chrome it launched.
72
+ */
73
+ export const killTree = (pid, { timeoutMs = 10000 } = {}) =>
74
+ new Promise((resolve) => {
75
+ if (process.platform !== 'win32') {
76
+ try {
77
+ process.kill(-pid, 'SIGKILL');
78
+ } catch {
79
+ try {
80
+ process.kill(pid, 'SIGKILL');
81
+ } catch {
82
+ // Already gone.
83
+ }
84
+ }
85
+ resolve();
86
+ return;
87
+ }
88
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
89
+ // Even the killer gets a deadline: cleanup that can hang is one more way for a
90
+ // sweep to stop without saying anything.
91
+ const timer = setTimeout(() => resolve(), timeoutMs);
92
+ const done = () => {
93
+ clearTimeout(timer);
94
+ resolve();
95
+ };
96
+ killer.on('close', done);
97
+ killer.on('error', done);
98
+ });
99
+
100
+ export const launchSession = async ({
101
+ profileDir,
102
+ port,
103
+ headless = true,
104
+ chromePath = DEFAULT_CHROME,
105
+ extraArgs = [],
106
+ keepProfile = false,
107
+ } = {}) => {
108
+ const chosenPort = port ?? (await findFreePort());
109
+ // Chrome must be given an absolute --user-data-dir: with a relative one it can
110
+ // start against a different directory than the one seeded with the flag, or fail
111
+ // to start at all, and the only symptom is a DevTools port that never answers.
112
+ const dir = resolve(profileDir ?? `artifacts/sessions/session-${chosenPort}`);
113
+
114
+ await rm(dir, { recursive: true, force: true });
115
+ await mkdir(dir, { recursive: true });
116
+ await writeFile(`${dir}/Local State`, WEBMCP_FLAG_PROFILE, 'utf8');
117
+
118
+ const args = [
119
+ `--remote-debugging-port=${chosenPort}`,
120
+ `--user-data-dir=${dir}`,
121
+ '--no-first-run',
122
+ '--no-default-browser-check',
123
+ '--disable-background-networking',
124
+ '--disable-sync',
125
+ ...(headless ? ['--headless=new'] : []),
126
+ ...extraArgs,
127
+ 'about:blank',
128
+ ];
129
+
130
+ const child = spawn(chromePath, args, {
131
+ // Chrome's own stderr is the only place a launch failure explains itself, and
132
+ // swallowing it turns any startup problem into an unhelpful timeout — which is
133
+ // exactly what happened in CI on 2026-09-02: "did not expose DevTools within
134
+ // 30000ms" with no reason attached. So it is captured always, and the tail is
135
+ // attached to the error; `WEBMCP_GAUGE_CHROME_LOG` still streams it live.
136
+ stdio: process.env.WEBMCP_GAUGE_CHROME_LOG ? 'inherit' : ['ignore', 'ignore', 'pipe'],
137
+ detached: process.platform !== 'win32',
138
+ });
139
+
140
+ let stderr = '';
141
+ child.stderr?.on('data', (chunk) => {
142
+ stderr = `${stderr}${chunk}`.slice(-4000);
143
+ });
144
+
145
+ let exited = null;
146
+ child.on('exit', (code, signal) => {
147
+ exited = { code, signal };
148
+ });
149
+
150
+ try {
151
+ const version = await waitForDevTools(chosenPort, {
152
+ // CI runners are slower and more variable than a developer's machine, and a
153
+ // fixed 30 s is a coin toss there rather than a diagnosis.
154
+ timeoutMs: Number(process.env.WEBMCP_GAUGE_LAUNCH_TIMEOUT_MS ?? 30000),
155
+ });
156
+ return {
157
+ port: String(chosenPort),
158
+ pid: child.pid,
159
+ profileDir: dir,
160
+ headless,
161
+ build: version.build,
162
+ protocol: version.protocol,
163
+ startedAt: new Date().toISOString(),
164
+ exitInfo: () => exited,
165
+ async close() {
166
+ if (child.pid) await killTree(child.pid);
167
+ if (!keepProfile) {
168
+ // Chrome releases the profile lock asynchronously; one retry is enough
169
+ // in practice and a failure here is not worth aborting a sweep over.
170
+ await new Promise((resolve) => setTimeout(resolve, 300));
171
+ await rm(dir, { recursive: true, force: true }).catch(async () => {
172
+ await new Promise((resolve) => setTimeout(resolve, 1500));
173
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
174
+ });
175
+ }
176
+ },
177
+ };
178
+ } catch (error) {
179
+ if (child.pid) await killTree(child.pid);
180
+ // Say why, not just that. The exit status and Chrome's own last words are the
181
+ // difference between "this runner is slow" and "this build cannot start here".
182
+ const detail = [
183
+ exited ? `chrome exited code=${exited.code} signal=${exited.signal}` : 'chrome was still running',
184
+ stderr.trim() ? `chrome stderr (tail): ${stderr.trim().split(/\r?\n/).slice(-6).join(' | ')}` : 'chrome wrote nothing to stderr',
185
+ ].join('; ');
186
+ throw new Error(`${error.message} — ${detail}`);
187
+ }
188
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A static file server for fixture pages, so a measurement can run against a page
3
+ * this repo controls.
4
+ *
5
+ * WebMCP needs a real origin: Chrome does not expose `document.modelContext` to
6
+ * `file://`, and the fixture fetches a CSV beside itself, which `file://` blocks
7
+ * anyway. This is deliberately the smallest thing that answers GET for a known set
8
+ * of extensions - it serves fixtures on 127.0.0.1 and nothing else, so it is not a
9
+ * general web server and must not grow into one.
10
+ *
11
+ * Port 0 lets the OS pick, which matters when sessions run concurrently: each
12
+ * session process starts its own server, exactly as it starts its own browser.
13
+ */
14
+ import { createServer } from 'node:http';
15
+ import { readFile, stat } from 'node:fs/promises';
16
+ import { extname, join, normalize, resolve, sep } from 'node:path';
17
+
18
+ const TYPES = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ '.mjs': 'text/javascript; charset=utf-8',
22
+ '.css': 'text/css; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.csv': 'text/csv; charset=utf-8',
25
+ '.svg': 'image/svg+xml',
26
+ };
27
+
28
+ export const startFixtureServer = async ({ root, port = 0, host = '127.0.0.1' }) => {
29
+ const rootPath = resolve(root);
30
+ const rootStat = await stat(rootPath).catch(() => null);
31
+ if (!rootStat?.isDirectory()) throw new Error(`--serve needs a directory, got ${rootPath}`);
32
+
33
+ const server = createServer(async (request, response) => {
34
+ const requested = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
35
+ const relative = normalize(requested).replace(/^([/\\])+/, '');
36
+ const filePath = join(rootPath, relative === '' ? 'index.html' : relative);
37
+
38
+ // A fixture server that can be walked out of its own directory is a hole, even
39
+ // on localhost: the harness runs it with the repo one level up.
40
+ if (filePath !== rootPath && !filePath.startsWith(rootPath + sep)) {
41
+ response.writeHead(403).end('outside the fixture root');
42
+ return;
43
+ }
44
+
45
+ try {
46
+ const body = await readFile(filePath);
47
+ response.writeHead(200, {
48
+ 'Content-Type': TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream',
49
+ 'Content-Length': body.length,
50
+ 'Cache-Control': 'no-store',
51
+ });
52
+ response.end(body);
53
+ } catch (error) {
54
+ response.writeHead(error.code === 'ENOENT' ? 404 : 500).end(String(error.code ?? error));
55
+ }
56
+ });
57
+
58
+ await new Promise((ready, failed) => {
59
+ server.once('error', failed);
60
+ server.listen(port, host, ready);
61
+ });
62
+
63
+ const address = server.address();
64
+
65
+ return {
66
+ port: address.port,
67
+ origin: `http://${host}:${address.port}`,
68
+ /** Resolves a fixture-relative path (query string included) against this server. */
69
+ urlFor(pathAndQuery) {
70
+ return new URL(String(pathAndQuery).replace(/^\/+/, ''), `http://${host}:${address.port}/`).href;
71
+ },
72
+ close: () =>
73
+ new Promise((closed) => {
74
+ server.closeAllConnections?.();
75
+ server.close(closed);
76
+ }),
77
+ };
78
+ };