@monoes/monobrowse 1.0.16 → 1.0.19
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__/browser-launch.test.js +132 -1
- package/dist/src/__tests__/browser-launch.test.js.map +1 -1
- 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 +148 -37
- 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/browser/types.d.ts +2 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.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__/browser-launch.test.ts +142 -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 +145 -34
- package/src/browser/cdp.ts +8 -1
- package/src/browser/types.ts +2 -0
- package/src/cli/commands-report.ts +46 -19
- package/src/report/util.ts +9 -3
package/package.json
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* services; each test binds/tears down its own listeners.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
13
|
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
13
14
|
import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
14
17
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
15
|
-
import { launchBrowser } from '../browser/browser.js';
|
|
18
|
+
import { getLaunchedPid, getLaunchedUserDataDir, launchBrowser } from '../browser/browser.js';
|
|
16
19
|
|
|
17
20
|
const BASE = 23470;
|
|
18
21
|
|
|
@@ -111,3 +114,141 @@ describe('launchBrowser — port scan/attach decisions', () => {
|
|
|
111
114
|
);
|
|
112
115
|
}, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
|
|
113
116
|
});
|
|
117
|
+
|
|
118
|
+
// A stand-in for the Chrome binary: like real Chrome given
|
|
119
|
+
// --remote-debugging-port=0, it binds a kernel-assigned port and only then
|
|
120
|
+
// writes that port to <user-data-dir>/DevToolsActivePort.
|
|
121
|
+
const FAKE_CHROME = `#!/usr/bin/env node
|
|
122
|
+
const { createServer } = require('node:http');
|
|
123
|
+
const { writeFileSync } = require('node:fs');
|
|
124
|
+
const { join } = require('node:path');
|
|
125
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
128
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
129
|
+
});
|
|
130
|
+
// Start slower than launchBrowser's first poll, so a stale file is read first.
|
|
131
|
+
setTimeout(() => server.listen(0, '127.0.0.1', () => {
|
|
132
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), server.address().port + '\\n/devtools/browser/fake');
|
|
133
|
+
}), 600);
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
describe.skipIf(process.platform === 'win32')(
|
|
137
|
+
'launchBrowser — port 0 (Chrome picks the port)',
|
|
138
|
+
() => {
|
|
139
|
+
it('returns the port its own Chrome reported, never another CDP endpoint', async () => {
|
|
140
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-port0-'));
|
|
141
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
142
|
+
writeFileSync(exe, FAKE_CHROME);
|
|
143
|
+
chmodSync(exe, 0o755);
|
|
144
|
+
const profile = join(dir, 'profile');
|
|
145
|
+
// A Chrome-looking endpoint that is NOT ours, named by a stale
|
|
146
|
+
// DevToolsActivePort left in the profile dir by an earlier run.
|
|
147
|
+
const foreign = BASE + 5;
|
|
148
|
+
const s = createHttpServer((req, res) => {
|
|
149
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/1.0' }) : '[]');
|
|
151
|
+
});
|
|
152
|
+
s.on('connection', (sock) => sockets.push(sock));
|
|
153
|
+
await new Promise<void>((resolve) => s.listen(foreign, '127.0.0.1', () => resolve()));
|
|
154
|
+
servers.push(s);
|
|
155
|
+
mkdirSync(profile);
|
|
156
|
+
writeFileSync(join(profile, 'DevToolsActivePort'), `${foreign}\n/devtools/browser/stale`);
|
|
157
|
+
|
|
158
|
+
let port: number | undefined;
|
|
159
|
+
try {
|
|
160
|
+
port = await launchBrowser({ port: 0, userDataDir: profile, executablePath: exe });
|
|
161
|
+
expect(port).not.toBe(foreign);
|
|
162
|
+
expect(port).toBeGreaterThan(0);
|
|
163
|
+
expect(getLaunchedPid(port)).toBeTypeOf('number');
|
|
164
|
+
} finally {
|
|
165
|
+
const pid = port === undefined ? undefined : getLaunchedPid(port);
|
|
166
|
+
if (pid) process.kill(pid, 'SIGKILL');
|
|
167
|
+
rmSync(dir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses port 0 without a dedicated userDataDir', async () => {
|
|
172
|
+
await expect(launchBrowser({ port: 0 })).rejects.toThrow(/requires a dedicated userDataDir/);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// A stand-in for the Chrome binary that, unlike FAKE_CHROME above, actually
|
|
178
|
+
// binds the FIXED port it is given via --remote-debugging-port (real Chrome,
|
|
179
|
+
// launched with no --port flag, always resolves to the same literal default
|
|
180
|
+
// port too — see cli/commands.ts's `port` option default). If the bind
|
|
181
|
+
// fails (another process already has it), it exits the way real Chrome does
|
|
182
|
+
// when a concurrent launch wins the same "free-looking" port: before its
|
|
183
|
+
// CDP endpoint ever opens.
|
|
184
|
+
const FAKE_CHROME_FIXED_PORT = `#!/usr/bin/env node
|
|
185
|
+
const { createServer } = require('node:http');
|
|
186
|
+
const { writeFileSync, mkdirSync } = require('node:fs');
|
|
187
|
+
const { join } = require('node:path');
|
|
188
|
+
const port = Number(
|
|
189
|
+
process.argv.find((a) => a.startsWith('--remote-debugging-port=')).slice('--remote-debugging-port='.length),
|
|
190
|
+
);
|
|
191
|
+
const dir = process.argv.find((a) => a.startsWith('--user-data-dir=')).slice('--user-data-dir='.length);
|
|
192
|
+
const server = createServer((req, res) => {
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
194
|
+
res.end(req.url === '/json/version' ? JSON.stringify({ Browser: 'Chrome/999.0.0.0' }) : '[]');
|
|
195
|
+
});
|
|
196
|
+
server.on('error', () => {
|
|
197
|
+
process.exit(21);
|
|
198
|
+
});
|
|
199
|
+
server.listen(port, '127.0.0.1', () => {
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, 'DevToolsActivePort'), port + '\\n/devtools/browser/fake');
|
|
202
|
+
});
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
describe.skipIf(process.platform === 'win32')(
|
|
206
|
+
'launchBrowser — concurrent launches with no explicit --port (TOCTOU race)',
|
|
207
|
+
() => {
|
|
208
|
+
it('two concurrent launches racing for the same default port both succeed, on different ports and profiles', async () => {
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), 'monobrowse-concurrent-'));
|
|
210
|
+
const exe = join(dir, 'fake-chrome.cjs');
|
|
211
|
+
writeFileSync(exe, FAKE_CHROME_FIXED_PORT);
|
|
212
|
+
chmodSync(exe, 0o755);
|
|
213
|
+
// Both calls target the SAME literal port on purpose: that is exactly
|
|
214
|
+
// what two real `monomind browse open` invocations with no --port flag
|
|
215
|
+
// do, since the CLI's `port` option always defaults to the same value
|
|
216
|
+
// whether or not the user passed it (cli/commands.ts:288).
|
|
217
|
+
const port = BASE + 10;
|
|
218
|
+
|
|
219
|
+
let ports: number[] = [];
|
|
220
|
+
try {
|
|
221
|
+
ports = await Promise.all([
|
|
222
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
223
|
+
launchBrowser({ port, executablePath: exe, launchTimeoutMs: 5000 }),
|
|
224
|
+
]);
|
|
225
|
+
} finally {
|
|
226
|
+
for (const p of ports) {
|
|
227
|
+
const pid = getLaunchedPid(p);
|
|
228
|
+
if (pid) {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 'SIGKILL');
|
|
231
|
+
} catch {
|
|
232
|
+
/* already gone */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
expect(ports).toHaveLength(2);
|
|
240
|
+
// The old probe-then-launch race let both calls observe the same
|
|
241
|
+
// candidate as free and spawn Chrome on it — one bind wins, the
|
|
242
|
+
// other's Chrome exits before its CDP endpoint opens and the whole
|
|
243
|
+
// launchBrowser() call rejects. Both resolving at all is the
|
|
244
|
+
// regression check; landing on different ports (rather than one
|
|
245
|
+
// silently adopting the other's browser) proves the scan actually
|
|
246
|
+
// moved on instead of colliding.
|
|
247
|
+
expect(ports[0]).not.toBe(ports[1]);
|
|
248
|
+
const dirs = ports.map((p) => getLaunchedUserDataDir(p));
|
|
249
|
+
expect(dirs[0]).not.toBe(dirs[1]);
|
|
250
|
+
expect(dirs[0]).toBeTruthy();
|
|
251
|
+
expect(dirs[1]).toBeTruthy();
|
|
252
|
+
}, 15000);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
@@ -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);
|