@monoes/monobrowse 1.0.16 → 1.0.17
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.
- package/dist/src/__tests__/report-exit-code.test.d.ts +46 -0
- package/dist/src/__tests__/report-exit-code.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-exit-code.test.js +188 -0
- package/dist/src/__tests__/report-exit-code.test.js.map +1 -0
- package/dist/src/browser/actions.d.ts.map +1 -1
- package/dist/src/browser/actions.js +19 -9
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/bridge.d.ts.map +1 -1
- package/dist/src/browser/bridge.js +4 -1
- package/dist/src/browser/bridge.js.map +1 -1
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +20 -4
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +8 -1
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/cli/commands-report.d.ts.map +1 -1
- package/dist/src/cli/commands-report.js +46 -20
- package/dist/src/cli/commands-report.js.map +1 -1
- package/dist/src/report/util.d.ts +4 -0
- package/dist/src/report/util.d.ts.map +1 -1
- package/dist/src/report/util.js +9 -3
- package/dist/src/report/util.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/report-exit-code.test.ts +207 -0
- package/src/browser/actions.ts +21 -12
- package/src/browser/bridge.ts +4 -1
- package/src/browser/browser.ts +19 -4
- package/src/browser/cdp.ts +8 -1
- package/src/cli/commands-report.ts +46 -19
- package/src/report/util.ts +9 -3
package/package.json
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `monobrowse report` exited 0, and printed nothing at all, on a page that
|
|
3
|
+
* FAILED its budgets — while the report JSON it had already written on disk
|
|
4
|
+
* said `"verdict": "fail"`. Anything gating on exit status (CI, an agent, a
|
|
5
|
+
* shell `&&` chain) saw green on a broken page, which defeats the entire
|
|
6
|
+
* point of the budgets feature.
|
|
7
|
+
*
|
|
8
|
+
* Two things combined to cause it:
|
|
9
|
+
*
|
|
10
|
+
* 1. closeBrowser() could hang. Its timers were unref'd, so once Chrome's
|
|
11
|
+
* CDP socket went away nothing was left holding the event loop open and
|
|
12
|
+
* Node exited before those timers ever fired. (browser.ts; the poll-loop
|
|
13
|
+
* half of this is #314's launch-close-settle.test.ts.)
|
|
14
|
+
*
|
|
15
|
+
* 2. commands-report.ts awaited that teardown in a `finally` positioned
|
|
16
|
+
* BETWEEN producing the result and printing it. So a hung teardown took
|
|
17
|
+
* the verdict down with it: the print never ran, `exitCode: 1` was never
|
|
18
|
+
* returned, and Node drained the loop and exited 0.
|
|
19
|
+
*
|
|
20
|
+
* (1) alone is not enough to make this safe — any future teardown hang would
|
|
21
|
+
* silently reintroduce it. (2) is the ordering fix: print the verdict and fix
|
|
22
|
+
* process.exitCode BEFORE teardown, so a teardown that never settles can no
|
|
23
|
+
* longer turn a failing page green.
|
|
24
|
+
*
|
|
25
|
+
* WHY THIS IS A SUBPROCESS TEST. The bug only exists in the semantics of a
|
|
26
|
+
* real process exit — "the event loop drained while a promise was still
|
|
27
|
+
* pending, so Node exited with the code it had". An in-process test cannot
|
|
28
|
+
* observe that: vitest's own runner pins the event loop, so the hang simply
|
|
29
|
+
* becomes a pending promise and the exit code is never consulted. (#314's
|
|
30
|
+
* test says the same thing about fake timers.) So this spawns a real `node`
|
|
31
|
+
* and asserts the code the OS sees.
|
|
32
|
+
*
|
|
33
|
+
* WHY IT NEEDS NO CHROME. The suite is deliberately browser-free (see the
|
|
34
|
+
* monobrowse note in .github/workflows/tests.yml) and this keeps it that way.
|
|
35
|
+
* The child imports the REAL built commands-report.js, but next to stub
|
|
36
|
+
* `session.js` / `report/index.js` modules in a temp sandbox — commands-report
|
|
37
|
+
* has only those two static imports, and output.js has none, so copying three
|
|
38
|
+
* files reproduces its module graph exactly with no loader hooks and no
|
|
39
|
+
* Node-version-specific API. The stub's closeBrowser() is the hang, modelled
|
|
40
|
+
* as a promise that never settles — which is precisely what an unref'd timer
|
|
41
|
+
* on a dead socket degrades into.
|
|
42
|
+
*
|
|
43
|
+
* Requires `npm run build` (CI's monobrowse job builds before `npm test`).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { spawn } from 'node:child_process';
|
|
47
|
+
import { existsSync } from 'node:fs';
|
|
48
|
+
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
49
|
+
import { tmpdir } from 'node:os';
|
|
50
|
+
import { dirname, join } from 'node:path';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
53
|
+
|
|
54
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const distCli = join(here, '..', '..', 'dist', 'src', 'cli');
|
|
56
|
+
const hasBuild = existsSync(join(distCli, 'commands-report.js'));
|
|
57
|
+
|
|
58
|
+
let sandbox: string;
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
sandbox = await mkdtemp(join(tmpdir(), 'monobrowse-report-exit-'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
await rm(sandbox, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Stub `cli/session.js`. `closeBrowser` is the failure under test. */
|
|
69
|
+
function sessionStub(mode: 'hang' | 'clean'): string {
|
|
70
|
+
const close =
|
|
71
|
+
mode === 'hang'
|
|
72
|
+
? // Never settles — a teardown that is wedged forever. An unref'd timer
|
|
73
|
+
// racing a dead socket degrades into exactly this.
|
|
74
|
+
'closeBrowser: () => new Promise(() => {}),'
|
|
75
|
+
: 'closeBrowser: async () => {},';
|
|
76
|
+
return `
|
|
77
|
+
export const session = {
|
|
78
|
+
client: null, sessionId: '', targetId: '', port: 9222,
|
|
79
|
+
refs: new Map(), parentSessionId: '',
|
|
80
|
+
};
|
|
81
|
+
export async function ensureConnected(port) {
|
|
82
|
+
session.client = {}; session.sessionId = 'stub-session';
|
|
83
|
+
return { client: session.client, sessionId: session.sessionId };
|
|
84
|
+
}
|
|
85
|
+
export function print(line) { console.log(line); }
|
|
86
|
+
export async function getBrowser() {
|
|
87
|
+
return {
|
|
88
|
+
// Non-undefined so the teardown branch actually runs (it is skipped for
|
|
89
|
+
// a browser this process did not launch).
|
|
90
|
+
getLaunchedPid: () => 4242,
|
|
91
|
+
stopRequestCapture: () => {},
|
|
92
|
+
teardownConsoleCapture: () => {},
|
|
93
|
+
${close}
|
|
94
|
+
clearActivePort: async () => {},
|
|
95
|
+
clearRefCache: async () => {},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function ensureSignalCleanupHandlers() {}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stub `report/index.js` — a run that FAILED its budgets. */
|
|
103
|
+
const reportStub = `
|
|
104
|
+
const report = {
|
|
105
|
+
verdict: 'fail',
|
|
106
|
+
failures: [{ budget: 'maxConsoleErrors', expected: '<= 0', actual: 3, detail: 'boom' }],
|
|
107
|
+
notes: [],
|
|
108
|
+
trend: undefined,
|
|
109
|
+
diff: undefined,
|
|
110
|
+
};
|
|
111
|
+
export async function runReport() {
|
|
112
|
+
return {
|
|
113
|
+
report,
|
|
114
|
+
htmlPath: '/stub/r.html',
|
|
115
|
+
jsonPath: '/stub/r.json',
|
|
116
|
+
summary: '✗ FAIL — 1 budget failure(s)',
|
|
117
|
+
historyDir: undefined,
|
|
118
|
+
flake: undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export const runReportRepeated = runReport;
|
|
122
|
+
export async function readHistory() { return { dir: '/stub', runs: [] }; }
|
|
123
|
+
export function toJsonReport(r) { return r; }
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
/** Harness: mirrors cli.ts's real shape — `main().then(() => process.exit(...))`,
|
|
127
|
+
* NOT a top-level await. That distinction is the whole bug: with a top-level
|
|
128
|
+
* await Node notices the unsettled promise and exits 13, but cli.ts's promise
|
|
129
|
+
* chain simply never runs its `.then`, so the loop drains and the process
|
|
130
|
+
* exits with whatever process.exitCode holds — 0, unless someone set it. */
|
|
131
|
+
const harness = `
|
|
132
|
+
import { reportCommand } from './cli/commands-report.js';
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
const result = await reportCommand.action({
|
|
136
|
+
args: ['http://127.0.0.1:1/broken.html'],
|
|
137
|
+
flags: { out: '/stub/r.html' },
|
|
138
|
+
cwd: process.cwd(),
|
|
139
|
+
interactive: false,
|
|
140
|
+
});
|
|
141
|
+
// Only reached when teardown settles. cli.ts does exactly this.
|
|
142
|
+
if (result && !result.success) process.exitCode = result.exitCode ?? 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main()
|
|
146
|
+
.then(() => { process.exit(process.exitCode ?? 0); })
|
|
147
|
+
.catch((err) => { console.error(err?.message ?? String(err)); process.exit(1); });
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
async function runChild(mode: 'hang' | 'clean'): Promise<{
|
|
151
|
+
code: number | null;
|
|
152
|
+
stdout: string;
|
|
153
|
+
stderr: string;
|
|
154
|
+
}> {
|
|
155
|
+
await mkdir(join(sandbox, 'cli'), { recursive: true });
|
|
156
|
+
await mkdir(join(sandbox, 'report'), { recursive: true });
|
|
157
|
+
await cp(join(distCli, 'commands-report.js'), join(sandbox, 'cli', 'commands-report.js'));
|
|
158
|
+
await cp(join(distCli, 'output.js'), join(sandbox, 'cli', 'output.js'));
|
|
159
|
+
await writeFile(join(sandbox, 'cli', 'session.js'), sessionStub(mode), 'utf-8');
|
|
160
|
+
await writeFile(join(sandbox, 'report', 'index.js'), reportStub, 'utf-8');
|
|
161
|
+
await writeFile(join(sandbox, 'run.mjs'), harness, 'utf-8');
|
|
162
|
+
|
|
163
|
+
return await new Promise((resolve) => {
|
|
164
|
+
const child = spawn(process.execPath, [join(sandbox, 'run.mjs')], {
|
|
165
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
166
|
+
cwd: sandbox,
|
|
167
|
+
});
|
|
168
|
+
let stdout = '';
|
|
169
|
+
let stderr = '';
|
|
170
|
+
child.stdout.on('data', (d) => (stdout += String(d)));
|
|
171
|
+
child.stderr.on('data', (d) => (stderr += String(d)));
|
|
172
|
+
// A hung teardown must still let the process EXIT (by draining), so if
|
|
173
|
+
// the child is genuinely still alive here the test should fail loudly
|
|
174
|
+
// rather than hang the suite.
|
|
175
|
+
const kill = setTimeout(() => child.kill('SIGKILL'), 15_000);
|
|
176
|
+
child.on('close', (code) => {
|
|
177
|
+
clearTimeout(kill);
|
|
178
|
+
resolve({ code, stdout, stderr });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe.skipIf(!hasBuild)('report exit code survives a hung browser teardown', () => {
|
|
184
|
+
it('exits non-zero and prints the verdict even when closeBrowser never settles', async () => {
|
|
185
|
+
const { code, stdout, stderr } = await runChild('hang');
|
|
186
|
+
|
|
187
|
+
// THE BUG: this was 0 — a failing page reported as success, with the
|
|
188
|
+
// process exiting cleanly because the loop drained mid-teardown.
|
|
189
|
+
expect(code).toBe(1);
|
|
190
|
+
// ...and the verdict was never printed at all.
|
|
191
|
+
expect(stderr + stdout).toContain('FAIL');
|
|
192
|
+
expect(stdout).toContain('maxConsoleErrors');
|
|
193
|
+
}, 30_000);
|
|
194
|
+
|
|
195
|
+
it('still exits non-zero on the normal path where teardown completes', async () => {
|
|
196
|
+
const { code, stdout, stderr } = await runChild('clean');
|
|
197
|
+
|
|
198
|
+
expect(code).toBe(1);
|
|
199
|
+
expect(stderr + stdout).toContain('FAIL');
|
|
200
|
+
}, 30_000);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe.skipIf(hasBuild)('report exit code subprocess test', () => {
|
|
204
|
+
it('is skipped because dist/ is not built (run `npm run build`)', () => {
|
|
205
|
+
expect(hasBuild).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/browser/actions.ts
CHANGED
|
@@ -680,18 +680,27 @@ export async function evaluateJs(
|
|
|
680
680
|
sessionId,
|
|
681
681
|
);
|
|
682
682
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
683
|
+
// Not unref'd (same rule as CdpClient.send): this timer settles the awaited
|
|
684
|
+
// race, so it has to hold the event loop open while the evaluation is in
|
|
685
|
+
// flight. Cleared in the finally so a fast evaluation does not keep the
|
|
686
|
+
// process alive for the rest of timeoutMs.
|
|
687
|
+
let evalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
688
|
+
let result: Awaited<typeof evalPromise>;
|
|
689
|
+
try {
|
|
690
|
+
result = await (timeoutMs > 0
|
|
691
|
+
? Promise.race([
|
|
692
|
+
evalPromise,
|
|
693
|
+
new Promise<never>((_, reject) => {
|
|
694
|
+
evalTimer = setTimeout(
|
|
695
|
+
() => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)),
|
|
696
|
+
timeoutMs,
|
|
697
|
+
);
|
|
698
|
+
}),
|
|
699
|
+
])
|
|
700
|
+
: evalPromise);
|
|
701
|
+
} finally {
|
|
702
|
+
clearTimeout(evalTimer);
|
|
703
|
+
}
|
|
695
704
|
|
|
696
705
|
if (result.exceptionDetails) {
|
|
697
706
|
throw new Error(
|
package/src/browser/bridge.ts
CHANGED
|
@@ -237,13 +237,16 @@ export class BridgeTransport implements CdpTransport {
|
|
|
237
237
|
const envelope: BridgeEnvelope = { id, type, params };
|
|
238
238
|
if (this.tabId) envelope.tabId = this.tabId;
|
|
239
239
|
return new Promise<BridgeReply>((resolve, reject) => {
|
|
240
|
+
// Not unref'd (same rule as CdpClient.send): this timer settles a
|
|
241
|
+
// promise the caller awaits, so it must count toward keeping the event
|
|
242
|
+
// loop alive — otherwise a dead bridge socket lets Node exit 0 rather
|
|
243
|
+
// than surfacing the timeout. Cleared on every settle path below.
|
|
240
244
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
241
245
|
if (timeoutMs > 0) {
|
|
242
246
|
timer = setTimeout(() => {
|
|
243
247
|
this.pending.delete(id);
|
|
244
248
|
reject(new Error(`bridge ${type} timed out after ${timeoutMs}ms`));
|
|
245
249
|
}, timeoutMs);
|
|
246
|
-
timer.unref?.();
|
|
247
250
|
}
|
|
248
251
|
this.pending.set(id, (reply) => {
|
|
249
252
|
if (timer) clearTimeout(timer);
|
package/src/browser/browser.ts
CHANGED
|
@@ -363,20 +363,30 @@ export async function connectToTarget(
|
|
|
363
363
|
*/
|
|
364
364
|
export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
|
|
365
365
|
let gracefullyClosed = false;
|
|
366
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
366
367
|
try {
|
|
367
368
|
await Promise.race([
|
|
368
369
|
client.send('Browser.close', {}),
|
|
370
|
+
// Deliberately NOT unref'd, for the same reason waitForProcessExit()'s
|
|
371
|
+
// poll below is not. This timer is the only thing that settles the race
|
|
372
|
+
// when Chrome's socket dies without ever acknowledging the close, and
|
|
373
|
+
// closeBrowser() is actively awaited by callers. An unref'd timer does
|
|
374
|
+
// not hold the event loop open, so Node would consider the loop drained
|
|
375
|
+
// and exit — status 0, before the caller's verdict was ever printed.
|
|
376
|
+
// Cleared in the finally so a close that IS acknowledged does not pin
|
|
377
|
+
// the loop for the rest of BROWSER_CLOSE_TIMEOUT_MS.
|
|
369
378
|
new Promise<never>((_, reject) => {
|
|
370
|
-
|
|
379
|
+
closeTimer = setTimeout(
|
|
371
380
|
() => reject(new Error('Browser.close timed out')),
|
|
372
381
|
BROWSER_CLOSE_TIMEOUT_MS,
|
|
373
382
|
);
|
|
374
|
-
t.unref?.();
|
|
375
383
|
}),
|
|
376
384
|
]);
|
|
377
385
|
gracefullyClosed = true;
|
|
378
386
|
} catch {
|
|
379
387
|
// fall through to PID-kill fallback below
|
|
388
|
+
} finally {
|
|
389
|
+
clearTimeout(closeTimer);
|
|
380
390
|
}
|
|
381
391
|
|
|
382
392
|
let pid = launchedPids.get(port);
|
|
@@ -501,22 +511,27 @@ export async function reapIdleLaunchedBrowser(): Promise<number | null> {
|
|
|
501
511
|
if ((await fetchTargets(session.port)).length > 0) return null;
|
|
502
512
|
|
|
503
513
|
const client = new CdpClient();
|
|
514
|
+
let reapTimer: ReturnType<typeof setTimeout> | undefined;
|
|
504
515
|
try {
|
|
505
516
|
// CdpClient.connect() has no timeout of its own — a socket that never
|
|
506
517
|
// opens (and never errors) would hang the launch this reap is running
|
|
507
518
|
// inside of, which is strictly worse than leaving the port squatted.
|
|
519
|
+
// Not unref'd (see closeBrowser): a socket that never opens and never
|
|
520
|
+
// errors leaves this timer as the only thing that can settle the race,
|
|
521
|
+
// and the reap is awaited inside launch/attach. Cleared in the finally
|
|
522
|
+
// below so a connect that succeeds does not pin the event loop.
|
|
508
523
|
await Promise.race([
|
|
509
524
|
client.connect(wsUrl),
|
|
510
525
|
new Promise<never>((_, reject) => {
|
|
511
|
-
|
|
526
|
+
reapTimer = setTimeout(
|
|
512
527
|
() => reject(new Error('Reap: CDP connect timed out')),
|
|
513
528
|
REAP_CONNECT_TIMEOUT_MS,
|
|
514
529
|
);
|
|
515
|
-
t.unref?.();
|
|
516
530
|
}),
|
|
517
531
|
]);
|
|
518
532
|
await closeBrowser(client, session.port);
|
|
519
533
|
} finally {
|
|
534
|
+
clearTimeout(reapTimer);
|
|
520
535
|
try {
|
|
521
536
|
client.close();
|
|
522
537
|
} catch {
|
package/src/browser/cdp.ts
CHANGED
|
@@ -125,13 +125,20 @@ export class CdpClient {
|
|
|
125
125
|
const cmd: CdpCommand = { id, method, params };
|
|
126
126
|
if (sessionId) cmd.sessionId = sessionId;
|
|
127
127
|
|
|
128
|
+
// This timer is deliberately NOT unref'd. It is the backstop for the
|
|
129
|
+
// exact case described at the top of this file — a socket that dies
|
|
130
|
+
// without ever firing 'close'/'error', so flushPending() never runs.
|
|
131
|
+
// send()'s promise is awaited by essentially every caller; an unref'd
|
|
132
|
+
// timer cannot hold the event loop open, so Node would drain and exit
|
|
133
|
+
// (status 0, no output) instead of rejecting with a timeout the caller
|
|
134
|
+
// can report. Every settle path below clears it, so a command that gets
|
|
135
|
+
// its response never keeps the loop alive.
|
|
128
136
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
129
137
|
if (timeoutMs > 0) {
|
|
130
138
|
timer = setTimeout(() => {
|
|
131
139
|
this.pendingCommands.delete(id);
|
|
132
140
|
reject(new Error(`CDP command "${method}" timed out after ${timeoutMs}ms`));
|
|
133
141
|
}, timeoutMs);
|
|
134
|
-
timer.unref?.();
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
this.pendingCommands.set(id, {
|
|
@@ -186,6 +186,34 @@ export const reportCommand: Command = {
|
|
|
186
186
|
trendWindow: ctx.flags['trend-window'] as number | undefined,
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
// Closing the browser is cleanup, not part of the verdict. It used to run
|
|
190
|
+
// in a `finally` sitting between "we have the result" and "we print it",
|
|
191
|
+
// which meant anything that wedged the teardown — a Chrome that never
|
|
192
|
+
// acknowledged `Browser.close`, a CDP socket that died silently — took the
|
|
193
|
+
// verdict down with it: no output at all, and exit 0 on a page that FAILED
|
|
194
|
+
// its budgets. That is exactly the signal CI, agents and `&&` chains gate
|
|
195
|
+
// on, so a hung teardown reported success on a broken page. The verdict is
|
|
196
|
+
// now printed and the exit code fixed BEFORE any of this runs.
|
|
197
|
+
const teardown = async (): Promise<void> => {
|
|
198
|
+
// A report is a one-shot command — CI should not be left with an
|
|
199
|
+
// orphan Chrome. Only close a browser THIS process launched: an
|
|
200
|
+
// attached one belongs to the user's own `open`/`connect` session.
|
|
201
|
+
if (ctx.flags['keep-open'] || browser.getLaunchedPid(session.port) === undefined) return;
|
|
202
|
+
browser.stopRequestCapture(sessionId);
|
|
203
|
+
browser.teardownConsoleCapture(sessionId);
|
|
204
|
+
try {
|
|
205
|
+
await browser.closeBrowser(client, session.port);
|
|
206
|
+
} catch {
|
|
207
|
+
/* best-effort */
|
|
208
|
+
}
|
|
209
|
+
session.client = null;
|
|
210
|
+
session.sessionId = '';
|
|
211
|
+
session.targetId = '';
|
|
212
|
+
session.refs = new Map();
|
|
213
|
+
await browser.clearActivePort();
|
|
214
|
+
await browser.clearRefCache();
|
|
215
|
+
};
|
|
216
|
+
|
|
189
217
|
let result:
|
|
190
218
|
| Awaited<ReturnType<typeof runReport>>
|
|
191
219
|
| Awaited<ReturnType<typeof runReportRepeated>>;
|
|
@@ -193,25 +221,11 @@ export const reportCommand: Command = {
|
|
|
193
221
|
result = repeat
|
|
194
222
|
? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
|
|
195
223
|
: await runReport(client, sessionId, runOptions);
|
|
196
|
-
}
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
browser.stopRequestCapture(sessionId);
|
|
202
|
-
browser.teardownConsoleCapture(sessionId);
|
|
203
|
-
try {
|
|
204
|
-
await browser.closeBrowser(client, session.port);
|
|
205
|
-
} catch {
|
|
206
|
-
/* best-effort */
|
|
207
|
-
}
|
|
208
|
-
session.client = null;
|
|
209
|
-
session.sessionId = '';
|
|
210
|
-
session.targetId = '';
|
|
211
|
-
session.refs = new Map();
|
|
212
|
-
await browser.clearActivePort();
|
|
213
|
-
await browser.clearRefCache();
|
|
214
|
-
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// No verdict to print on this path, so the old ordering still applies:
|
|
226
|
+
// clean up, then let the error propagate to the CLI's error handler.
|
|
227
|
+
await teardown();
|
|
228
|
+
throw err;
|
|
215
229
|
}
|
|
216
230
|
|
|
217
231
|
const { report, htmlPath, jsonPath, summary, historyDir } = result;
|
|
@@ -220,6 +234,15 @@ export const reportCommand: Command = {
|
|
|
220
234
|
// runs must not exit 0 just because the last run happened to be green.
|
|
221
235
|
const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
|
|
222
236
|
|
|
237
|
+
// Set the process-wide failure code up front, before printing and before
|
|
238
|
+
// teardown. Returning `exitCode` below is the normal channel, but it only
|
|
239
|
+
// reaches the CLI if this function returns; if teardown never settles,
|
|
240
|
+
// Node drains the event loop and exits on its own — and a natural exit
|
|
241
|
+
// uses process.exitCode. Setting it here means a failing page cannot exit
|
|
242
|
+
// 0 no matter what happens after this line. Only ever set on failure, so
|
|
243
|
+
// this never clears a non-zero code set elsewhere.
|
|
244
|
+
if (!passed) process.exitCode = 1;
|
|
245
|
+
|
|
223
246
|
if (ctx.flags.json) {
|
|
224
247
|
const { toJsonReport } = await import('../report/index.js');
|
|
225
248
|
print(
|
|
@@ -265,6 +288,10 @@ export const reportCommand: Command = {
|
|
|
265
288
|
if (historyDir) print(`History: ${historyDir}`);
|
|
266
289
|
}
|
|
267
290
|
|
|
291
|
+
// Verdict is printed and the exit code is fixed — only now is it safe to
|
|
292
|
+
// close the browser.
|
|
293
|
+
await teardown();
|
|
294
|
+
|
|
268
295
|
// Non-zero exit is the point of RIG-06 — CI and agents gate on it.
|
|
269
296
|
return {
|
|
270
297
|
success: passed,
|
package/src/report/util.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
/** Budget for one non-navigation collector (AX tree, screenshot, eval). */
|
|
4
4
|
export const STEP_TIMEOUT_MS = 15_000;
|
|
5
5
|
|
|
6
|
+
/** Not unref'd: callers `await sleep(...)` (e.g. the web-vitals settle
|
|
7
|
+
* window), so this timer is on an actively-awaited path. An unref'd timer
|
|
8
|
+
* does not keep the event loop alive, so once the CDP socket closes Node
|
|
9
|
+
* would drain and exit mid-report instead of resuming after the sleep. */
|
|
6
10
|
export function sleep(ms: number): Promise<void> {
|
|
7
11
|
return new Promise((r) => {
|
|
8
|
-
|
|
9
|
-
t.unref?.();
|
|
12
|
+
setTimeout(r, ms);
|
|
10
13
|
});
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -23,9 +26,12 @@ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: str
|
|
|
23
26
|
try {
|
|
24
27
|
return await Promise.race([
|
|
25
28
|
promise,
|
|
29
|
+
// Not unref'd: this timer is the only thing that settles the race when
|
|
30
|
+
// `promise` is waiting on a socket that quietly went away, and every
|
|
31
|
+
// caller awaits the result. The finally below clears it, so work that
|
|
32
|
+
// finishes in time never holds the event loop open.
|
|
26
33
|
new Promise<never>((_, reject) => {
|
|
27
34
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
28
|
-
timer.unref?.();
|
|
29
35
|
}),
|
|
30
36
|
]);
|
|
31
37
|
} finally {
|