@silbercue/chrome 0.2.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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/build/cache/a11y-tree.d.ts +252 -0
- package/build/cache/a11y-tree.js +1956 -0
- package/build/cache/index.d.ts +8 -0
- package/build/cache/index.js +4 -0
- package/build/cache/selector-cache.d.ts +47 -0
- package/build/cache/selector-cache.js +119 -0
- package/build/cache/session-defaults.d.ts +27 -0
- package/build/cache/session-defaults.js +130 -0
- package/build/cache/tab-state-cache.d.ts +39 -0
- package/build/cache/tab-state-cache.js +171 -0
- package/build/cdp/cdp-client.d.ts +25 -0
- package/build/cdp/cdp-client.js +146 -0
- package/build/cdp/chrome-launcher.d.ts +85 -0
- package/build/cdp/chrome-launcher.js +502 -0
- package/build/cdp/console-collector.d.ts +53 -0
- package/build/cdp/console-collector.js +147 -0
- package/build/cdp/debug.d.ts +1 -0
- package/build/cdp/debug.js +6 -0
- package/build/cdp/dialog-handler.d.ts +54 -0
- package/build/cdp/dialog-handler.js +129 -0
- package/build/cdp/dom-watcher.d.ts +45 -0
- package/build/cdp/dom-watcher.js +195 -0
- package/build/cdp/emulation.d.ts +12 -0
- package/build/cdp/emulation.js +17 -0
- package/build/cdp/index.d.ts +11 -0
- package/build/cdp/index.js +6 -0
- package/build/cdp/network-collector.d.ts +77 -0
- package/build/cdp/network-collector.js +257 -0
- package/build/cdp/protocol.d.ts +20 -0
- package/build/cdp/protocol.js +1 -0
- package/build/cdp/session-manager.d.ts +62 -0
- package/build/cdp/session-manager.js +205 -0
- package/build/cdp/settle.d.ts +16 -0
- package/build/cdp/settle.js +71 -0
- package/build/cli/license-commands.d.ts +19 -0
- package/build/cli/license-commands.js +199 -0
- package/build/cli/top-level-commands.d.ts +49 -0
- package/build/cli/top-level-commands.js +222 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.js +1 -0
- package/build/hooks/pro-hooks.d.ts +126 -0
- package/build/hooks/pro-hooks.js +17 -0
- package/build/index.d.ts +4 -0
- package/build/index.js +86 -0
- package/build/license/free-tier-config.d.ts +14 -0
- package/build/license/free-tier-config.js +18 -0
- package/build/license/index.d.ts +4 -0
- package/build/license/index.js +2 -0
- package/build/license/license-status.d.ts +15 -0
- package/build/license/license-status.js +9 -0
- package/build/overlay/session-overlay.d.ts +22 -0
- package/build/overlay/session-overlay.js +372 -0
- package/build/plan/index.d.ts +7 -0
- package/build/plan/index.js +4 -0
- package/build/plan/plan-conditions.d.ts +12 -0
- package/build/plan/plan-conditions.js +242 -0
- package/build/plan/plan-executor.d.ts +49 -0
- package/build/plan/plan-executor.js +259 -0
- package/build/plan/plan-state-store.d.ts +24 -0
- package/build/plan/plan-state-store.js +43 -0
- package/build/plan/plan-variables.d.ts +16 -0
- package/build/plan/plan-variables.js +71 -0
- package/build/registry.d.ts +124 -0
- package/build/registry.js +884 -0
- package/build/server.d.ts +1 -0
- package/build/server.js +245 -0
- package/build/tools/click.d.ts +34 -0
- package/build/tools/click.js +293 -0
- package/build/tools/configure-session.d.ts +15 -0
- package/build/tools/configure-session.js +45 -0
- package/build/tools/console-logs.d.ts +18 -0
- package/build/tools/console-logs.js +44 -0
- package/build/tools/dom-snapshot.d.ts +13 -0
- package/build/tools/dom-snapshot.js +259 -0
- package/build/tools/element-utils.d.ts +23 -0
- package/build/tools/element-utils.js +133 -0
- package/build/tools/error-utils.d.ts +8 -0
- package/build/tools/error-utils.js +27 -0
- package/build/tools/evaluate.d.ts +34 -0
- package/build/tools/evaluate.js +217 -0
- package/build/tools/file-upload.d.ts +20 -0
- package/build/tools/file-upload.js +174 -0
- package/build/tools/fill-form.d.ts +39 -0
- package/build/tools/fill-form.js +256 -0
- package/build/tools/handle-dialog.d.ts +15 -0
- package/build/tools/handle-dialog.js +48 -0
- package/build/tools/index.d.ts +35 -0
- package/build/tools/index.js +18 -0
- package/build/tools/navigate.d.ts +18 -0
- package/build/tools/navigate.js +111 -0
- package/build/tools/network-monitor.d.ts +18 -0
- package/build/tools/network-monitor.js +66 -0
- package/build/tools/observe.d.ts +44 -0
- package/build/tools/observe.js +339 -0
- package/build/tools/press-key.d.ts +33 -0
- package/build/tools/press-key.js +155 -0
- package/build/tools/read-page.d.ts +22 -0
- package/build/tools/read-page.js +100 -0
- package/build/tools/run-plan.d.ts +205 -0
- package/build/tools/run-plan.js +215 -0
- package/build/tools/screenshot.d.ts +16 -0
- package/build/tools/screenshot.js +283 -0
- package/build/tools/scroll.d.ts +28 -0
- package/build/tools/scroll.js +143 -0
- package/build/tools/switch-tab.d.ts +26 -0
- package/build/tools/switch-tab.js +355 -0
- package/build/tools/tab-status.d.ts +7 -0
- package/build/tools/tab-status.js +50 -0
- package/build/tools/type.d.ts +31 -0
- package/build/tools/type.js +247 -0
- package/build/tools/virtual-desk.d.ts +7 -0
- package/build/tools/virtual-desk.js +108 -0
- package/build/tools/visual-constants.d.ts +3 -0
- package/build/tools/visual-constants.js +10 -0
- package/build/tools/wait-for.d.ts +26 -0
- package/build/tools/wait-for.js +323 -0
- package/build/transport/index.d.ts +3 -0
- package/build/transport/index.js +2 -0
- package/build/transport/pipe-transport.d.ts +18 -0
- package/build/transport/pipe-transport.js +63 -0
- package/build/transport/transport.d.ts +8 -0
- package/build/transport/transport.js +1 -0
- package/build/transport/websocket-transport.d.ts +22 -0
- package/build/transport/websocket-transport.js +200 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm, mkdir } from "node:fs/promises";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { CdpClient } from "./cdp-client.js";
|
|
9
|
+
import { PipeTransport } from "../transport/pipe-transport.js";
|
|
10
|
+
import { WebSocketTransport } from "../transport/websocket-transport.js";
|
|
11
|
+
import { debug } from "./debug.js";
|
|
12
|
+
// ── AutoLaunch Resolution (Story 10.2) ────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the autoLaunch setting from environment variables.
|
|
15
|
+
* Pure function — no side effects, fully testable.
|
|
16
|
+
*
|
|
17
|
+
* - SILBERCUE_CHROME_AUTO_LAUNCH=true → always auto-launch
|
|
18
|
+
* - SILBERCUE_CHROME_AUTO_LAUNCH=false → never auto-launch
|
|
19
|
+
* - unset → default: auto-launch (zero-config UX for new users)
|
|
20
|
+
*
|
|
21
|
+
* The `_headless` parameter is kept for backwards-compat with call-sites,
|
|
22
|
+
* but no longer influences the default — auto-launch is the standard path.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveAutoLaunch(env, _headless) {
|
|
25
|
+
const val = env.SILBERCUE_CHROME_AUTO_LAUNCH;
|
|
26
|
+
if (val === "true" || val === "1")
|
|
27
|
+
return true;
|
|
28
|
+
if (val === "false" || val === "0")
|
|
29
|
+
return false;
|
|
30
|
+
if (val === undefined) {
|
|
31
|
+
// Default: always auto-launch — user gets zero-config UX
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// Invalid env value (e.g. "foo", "bar") → safe default: no auto-launch
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// ── Chrome Path Detection (Task 1) ────────────────────────────────────
|
|
38
|
+
const CHROME_PATHS = {
|
|
39
|
+
darwin: [
|
|
40
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
41
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
42
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
43
|
+
],
|
|
44
|
+
linux: [
|
|
45
|
+
"google-chrome",
|
|
46
|
+
"google-chrome-stable",
|
|
47
|
+
"chromium-browser",
|
|
48
|
+
"chromium",
|
|
49
|
+
],
|
|
50
|
+
win32: [
|
|
51
|
+
`${process.env.ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
52
|
+
`${process.env["ProgramFiles(x86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
53
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
export function findChromePath() {
|
|
57
|
+
// CHROME_PATH env override
|
|
58
|
+
const envPath = process.env.CHROME_PATH;
|
|
59
|
+
if (envPath) {
|
|
60
|
+
if (existsSync(envPath))
|
|
61
|
+
return envPath;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const platform = process.platform;
|
|
65
|
+
const candidates = CHROME_PATHS[platform];
|
|
66
|
+
if (!candidates)
|
|
67
|
+
return null;
|
|
68
|
+
if (platform === "linux") {
|
|
69
|
+
// Linux: executable names — resolve via `which`
|
|
70
|
+
for (const name of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
const resolved = execFileSync("which", [name], {
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
75
|
+
}).trim();
|
|
76
|
+
if (resolved)
|
|
77
|
+
return resolved;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// not found, try next
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// macOS / Windows: absolute paths — check existence
|
|
86
|
+
for (const p of candidates) {
|
|
87
|
+
if (existsSync(p))
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// ── Chrome Spawn with CDP-Pipe (Task 2) ───────────────────────────────
|
|
93
|
+
const CHROME_FLAGS = [
|
|
94
|
+
"--remote-debugging-pipe",
|
|
95
|
+
"--no-first-run",
|
|
96
|
+
"--no-default-browser-check",
|
|
97
|
+
"--disable-default-apps",
|
|
98
|
+
"--disable-extensions",
|
|
99
|
+
"--disable-background-networking",
|
|
100
|
+
"--disable-background-timer-throttling",
|
|
101
|
+
"--disable-backgrounding-occluded-windows",
|
|
102
|
+
"--disable-renderer-backgrounding",
|
|
103
|
+
"--enable-features=CDPScreenshotNewSurface",
|
|
104
|
+
"--disable-sync",
|
|
105
|
+
"--mute-audio",
|
|
106
|
+
];
|
|
107
|
+
export async function launchChrome(options) {
|
|
108
|
+
const chromePath = findChromePath();
|
|
109
|
+
if (!chromePath) {
|
|
110
|
+
throw new Error("Chrome not found. Install Chrome or set CHROME_PATH environment variable.");
|
|
111
|
+
}
|
|
112
|
+
let userDataDir;
|
|
113
|
+
let tmpDir;
|
|
114
|
+
if (options?.profilePath) {
|
|
115
|
+
// Validate that the profile path exists
|
|
116
|
+
if (!existsSync(options.profilePath)) {
|
|
117
|
+
throw new Error(`Chrome profile path does not exist: ${options.profilePath}`);
|
|
118
|
+
}
|
|
119
|
+
userDataDir = options.profilePath;
|
|
120
|
+
// No tmpDir — profile directory must NEVER be deleted
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Default: isolated temp profile
|
|
124
|
+
tmpDir = join(tmpdir(), `silbercuechrome-${randomBytes(4).toString("hex")}`);
|
|
125
|
+
await mkdir(tmpDir, { recursive: true });
|
|
126
|
+
userDataDir = tmpDir;
|
|
127
|
+
}
|
|
128
|
+
const flags = [...CHROME_FLAGS, `--user-data-dir=${userDataDir}`];
|
|
129
|
+
if (options?.headless !== false) {
|
|
130
|
+
flags.unshift("--headless");
|
|
131
|
+
}
|
|
132
|
+
debug("Spawning Chrome: %s %s", chromePath, flags.join(" "));
|
|
133
|
+
const child = spawn(chromePath, flags, {
|
|
134
|
+
stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"],
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
const cdpReadable = child.stdio[4];
|
|
138
|
+
const cdpWritable = child.stdio[3];
|
|
139
|
+
const transport = new PipeTransport(cdpReadable, cdpWritable);
|
|
140
|
+
const cdpClient = new CdpClient(transport);
|
|
141
|
+
// Wait for Chrome to be ready — Browser.getVersion must succeed
|
|
142
|
+
// B1: 5s pipe startup timeout (NFR11/NFR14 compliance)
|
|
143
|
+
await Promise.race([
|
|
144
|
+
cdpClient.send("Browser.getVersion"),
|
|
145
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Chrome startup timed out after 5s")), 5_000)),
|
|
146
|
+
]);
|
|
147
|
+
return { cdpClient, transport, process: child, transportType: "pipe" };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
// Cleanup on failure — only delete temp directories, NEVER profile directories
|
|
151
|
+
child.kill();
|
|
152
|
+
if (tmpDir) {
|
|
153
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function fetchJsonVersion(port, timeoutMs = 500) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
if (settled)
|
|
163
|
+
return;
|
|
164
|
+
settled = true;
|
|
165
|
+
req.destroy();
|
|
166
|
+
reject(new Error(`/json/version request timed out after ${timeoutMs}ms`));
|
|
167
|
+
}, timeoutMs);
|
|
168
|
+
const req = httpRequest({
|
|
169
|
+
hostname: "127.0.0.1",
|
|
170
|
+
port,
|
|
171
|
+
path: "/json/version",
|
|
172
|
+
method: "GET",
|
|
173
|
+
timeout: timeoutMs,
|
|
174
|
+
}, (res) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
if (res.statusCode !== 200) {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
settled = true;
|
|
180
|
+
reject(new Error(`/json/version returned HTTP ${res.statusCode}`));
|
|
181
|
+
res.resume();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
let body = "";
|
|
185
|
+
res.on("data", (chunk) => {
|
|
186
|
+
body += chunk.toString();
|
|
187
|
+
});
|
|
188
|
+
res.on("end", () => {
|
|
189
|
+
if (settled)
|
|
190
|
+
return;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
settled = true;
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(body);
|
|
195
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
196
|
+
reject(new Error("/json/version returned invalid JSON"));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
resolve(parsed);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
reject(new Error("/json/version returned invalid JSON"));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
req.on("error", (err) => {
|
|
207
|
+
if (settled)
|
|
208
|
+
return;
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
settled = true;
|
|
211
|
+
reject(err);
|
|
212
|
+
});
|
|
213
|
+
req.end();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// ── ChromeConnection (Task 4 + Task 6) ────────────────────────────────
|
|
217
|
+
export class ChromeConnection {
|
|
218
|
+
transportType;
|
|
219
|
+
status = "connected";
|
|
220
|
+
_exitHandler = null;
|
|
221
|
+
_closed = false;
|
|
222
|
+
// Reconnect fields (Story 5.2)
|
|
223
|
+
_cdpClient;
|
|
224
|
+
_transport;
|
|
225
|
+
_childProcess;
|
|
226
|
+
_tmpDir;
|
|
227
|
+
_reconnecting = false;
|
|
228
|
+
_onReconnect = null;
|
|
229
|
+
_headless;
|
|
230
|
+
_port;
|
|
231
|
+
_profilePath;
|
|
232
|
+
constructor(cdpClient, transport, transportType, childProcess, tmpDir, _launcher, port, headless, profilePath) {
|
|
233
|
+
this.transportType = transportType;
|
|
234
|
+
this._cdpClient = cdpClient;
|
|
235
|
+
this._transport = transport;
|
|
236
|
+
this._childProcess = childProcess;
|
|
237
|
+
this._tmpDir = tmpDir;
|
|
238
|
+
this._port = port ?? 9222;
|
|
239
|
+
this._headless = headless ?? false;
|
|
240
|
+
this._profilePath = profilePath;
|
|
241
|
+
// C1 fix: Passive status tracking via CdpClient.onClose —
|
|
242
|
+
// detects unexpected transport close (WebSocket drop, pipe break)
|
|
243
|
+
this._setupOnClose(cdpClient);
|
|
244
|
+
if (this._childProcess) {
|
|
245
|
+
this._setupChildProcessHandlers(this._childProcess);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
get cdpClient() {
|
|
249
|
+
return this._cdpClient;
|
|
250
|
+
}
|
|
251
|
+
get transport() {
|
|
252
|
+
return this._transport;
|
|
253
|
+
}
|
|
254
|
+
get childProcess() {
|
|
255
|
+
return this._childProcess;
|
|
256
|
+
}
|
|
257
|
+
get headless() {
|
|
258
|
+
return this._headless;
|
|
259
|
+
}
|
|
260
|
+
/** Register a callback to be invoked after successful reconnect for re-wiring */
|
|
261
|
+
onReconnect(callback) {
|
|
262
|
+
this._onReconnect = callback;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Attempt to reconnect to Chrome with exponential backoff.
|
|
266
|
+
* BUG-004 fix: No race window (_reconnecting stays true during entire loop),
|
|
267
|
+
* failed transports are cleaned up, and onReconnect errors don't short-circuit.
|
|
268
|
+
* Returns true if reconnect succeeded, false otherwise.
|
|
269
|
+
*/
|
|
270
|
+
async reconnect() {
|
|
271
|
+
if (this._reconnecting || this._closed)
|
|
272
|
+
return false;
|
|
273
|
+
this._reconnecting = true;
|
|
274
|
+
this.status = "reconnecting";
|
|
275
|
+
// Best-effort close old client/transport
|
|
276
|
+
try {
|
|
277
|
+
await this._cdpClient.close();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* best-effort */
|
|
281
|
+
}
|
|
282
|
+
const maxAttempts = 5;
|
|
283
|
+
const baseDelay = 500; // Exponential backoff: 500, 1000, 2000, 4000ms
|
|
284
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
285
|
+
// B2: Check _closed inside the loop so close() during reconnect aborts immediately
|
|
286
|
+
if (this._closed) {
|
|
287
|
+
this._reconnecting = false;
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
if (attempt > 1) {
|
|
291
|
+
const delay = baseDelay * Math.pow(2, attempt - 2);
|
|
292
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
293
|
+
// B2: Re-check after the pause — close() may have been called while waiting
|
|
294
|
+
if (this._closed) {
|
|
295
|
+
this._reconnecting = false;
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
// BUG-004: Clean up transport from previous failed attempt to prevent leaks
|
|
299
|
+
try {
|
|
300
|
+
await this._cdpClient.close();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* best-effort */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
if (this.transportType === "websocket") {
|
|
308
|
+
// WebSocket reconnect: Chrome is still running, reconnect to same port
|
|
309
|
+
// B1: fetchJsonVersion uses 500ms default, WebSocket connect 2s
|
|
310
|
+
const versionInfo = await fetchJsonVersion(this._port);
|
|
311
|
+
if (!versionInfo.webSocketDebuggerUrl) {
|
|
312
|
+
throw new Error("Missing webSocketDebuggerUrl");
|
|
313
|
+
}
|
|
314
|
+
const wsUrl = versionInfo.webSocketDebuggerUrl;
|
|
315
|
+
const newTransport = await WebSocketTransport.connect(wsUrl, { timeoutMs: 2000 });
|
|
316
|
+
const newClient = new CdpClient(newTransport);
|
|
317
|
+
await newClient.send("Browser.getVersion");
|
|
318
|
+
this._transport = newTransport;
|
|
319
|
+
this._cdpClient = newClient;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Pipe reconnect: Chrome process is dead, relaunch
|
|
323
|
+
const result = await launchChrome({ headless: this._headless, profilePath: this._profilePath });
|
|
324
|
+
this._transport = result.transport;
|
|
325
|
+
this._cdpClient = result.cdpClient;
|
|
326
|
+
// Clean up old child process handlers
|
|
327
|
+
if (this._exitHandler) {
|
|
328
|
+
globalThis.process.removeListener("exit", this._exitHandler);
|
|
329
|
+
this._exitHandler = null;
|
|
330
|
+
}
|
|
331
|
+
this._childProcess = result.process;
|
|
332
|
+
if (this._profilePath) {
|
|
333
|
+
// Profile path: no tmpDir — profile directory must NEVER be deleted
|
|
334
|
+
this._tmpDir = undefined;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const tmpDirFlag = result.process.spawnargs.find((a) => a.startsWith("--user-data-dir="));
|
|
338
|
+
this._tmpDir = tmpDirFlag?.split("=")[1];
|
|
339
|
+
}
|
|
340
|
+
// Setup handlers for new child process
|
|
341
|
+
this._setupChildProcessHandlers(this._childProcess);
|
|
342
|
+
}
|
|
343
|
+
// Setup onClose for the new CdpClient to detect future disconnects
|
|
344
|
+
this._setupOnClose(this._cdpClient);
|
|
345
|
+
// Invoke onReconnect callback BEFORE setting status to connected.
|
|
346
|
+
// BUG-004 fix: If callback fails, DON'T throw — let the loop continue
|
|
347
|
+
// to the next attempt. _reconnecting stays true (no race window).
|
|
348
|
+
if (this._onReconnect) {
|
|
349
|
+
await this._onReconnect(this);
|
|
350
|
+
}
|
|
351
|
+
this.status = "connected";
|
|
352
|
+
this._reconnecting = false;
|
|
353
|
+
debug("Reconnect succeeded on attempt %d", attempt);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
358
|
+
debug("Reconnect attempt %d/%d failed: %s", attempt, maxAttempts, msg);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.status = "disconnected";
|
|
362
|
+
this._reconnecting = false;
|
|
363
|
+
debug("All %d reconnect attempts failed", maxAttempts);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
async close() {
|
|
367
|
+
if (this._closed)
|
|
368
|
+
return;
|
|
369
|
+
this._closed = true;
|
|
370
|
+
this.status = "disconnected";
|
|
371
|
+
// Remove process listeners to prevent accumulation
|
|
372
|
+
if (this._exitHandler)
|
|
373
|
+
globalThis.process.removeListener("exit", this._exitHandler);
|
|
374
|
+
// Close CDP client (which closes the transport)
|
|
375
|
+
await this._cdpClient.close();
|
|
376
|
+
// Terminate child process if we launched it
|
|
377
|
+
if (this._childProcess && !this._childProcess.killed) {
|
|
378
|
+
if (globalThis.process.platform === "win32") {
|
|
379
|
+
// H3 fix: On Windows, kill() sends taskkill — no SIGTERM/SIGKILL distinction
|
|
380
|
+
this._childProcess.kill();
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// POSIX: SIGTERM first, force SIGKILL after 5s
|
|
384
|
+
this._childProcess.kill("SIGTERM");
|
|
385
|
+
const forceTimer = setTimeout(() => {
|
|
386
|
+
if (!this._childProcess.killed) {
|
|
387
|
+
this._childProcess.kill("SIGKILL");
|
|
388
|
+
}
|
|
389
|
+
}, 5000);
|
|
390
|
+
forceTimer.unref();
|
|
391
|
+
this._childProcess.once("exit", () => clearTimeout(forceTimer));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Clean up tmp user-data-dir
|
|
395
|
+
if (this._tmpDir) {
|
|
396
|
+
await rm(this._tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** Register onClose callback on a CdpClient to trigger reconnect on unexpected disconnect.
|
|
400
|
+
* BUG-004 fix: fired-flag prevents handler accumulation across reconnect attempts. */
|
|
401
|
+
_setupOnClose(client) {
|
|
402
|
+
let fired = false;
|
|
403
|
+
client.onClose(() => {
|
|
404
|
+
if (fired || this._closed)
|
|
405
|
+
return;
|
|
406
|
+
fired = true;
|
|
407
|
+
this.status = "disconnected";
|
|
408
|
+
// Fire-and-forget reconnect
|
|
409
|
+
this.reconnect().catch((err) => {
|
|
410
|
+
debug("Reconnect error: %s", err instanceof Error ? err.message : String(err));
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** Setup child process exit handler and global exit cleanup */
|
|
415
|
+
_setupChildProcessHandlers(child) {
|
|
416
|
+
// Track status on child process exit + trigger reconnect
|
|
417
|
+
child.on("exit", () => {
|
|
418
|
+
if (this._closed)
|
|
419
|
+
return; // deliberate shutdown
|
|
420
|
+
this.status = "disconnected";
|
|
421
|
+
debug("Chrome process exited, attempting relaunch...");
|
|
422
|
+
this.reconnect().catch((err) => {
|
|
423
|
+
debug("Reconnect after crash error: %s", err instanceof Error ? err.message : String(err));
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
// H4 fix: Only register 'exit' handler for sync cleanup
|
|
427
|
+
this._exitHandler = () => {
|
|
428
|
+
if (this._closed)
|
|
429
|
+
return;
|
|
430
|
+
this._childProcess?.kill();
|
|
431
|
+
};
|
|
432
|
+
globalThis.process.on("exit", this._exitHandler);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ── ChromeLauncher (Task 4) ───────────────────────────────────────────
|
|
436
|
+
export class ChromeLauncher {
|
|
437
|
+
_port;
|
|
438
|
+
_autoLaunch;
|
|
439
|
+
_headless;
|
|
440
|
+
_profilePath;
|
|
441
|
+
constructor(options) {
|
|
442
|
+
this._port = options?.port ?? 9222;
|
|
443
|
+
this._autoLaunch = options?.autoLaunch ?? true;
|
|
444
|
+
this._headless = options?.headless ?? false;
|
|
445
|
+
this._profilePath = options?.profilePath;
|
|
446
|
+
}
|
|
447
|
+
async connect() {
|
|
448
|
+
// 1. Try WebSocket to existing Chrome
|
|
449
|
+
debug("Trying WebSocket on port %d...", this._port);
|
|
450
|
+
let wsError;
|
|
451
|
+
try {
|
|
452
|
+
return await this._connectViaWebSocket(this._port);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
wsError = err instanceof Error ? err : new Error(String(err));
|
|
456
|
+
debug("WebSocket failed: %s", wsError.message);
|
|
457
|
+
}
|
|
458
|
+
// 2. Auto-launch if enabled
|
|
459
|
+
// C2 fix: preserve original error for better diagnostics
|
|
460
|
+
if (!this._autoLaunch) {
|
|
461
|
+
throw wsError;
|
|
462
|
+
}
|
|
463
|
+
debug("Launching Chrome...");
|
|
464
|
+
const result = await launchChrome({ headless: this._headless, profilePath: this._profilePath });
|
|
465
|
+
// Extract tmpDir from the spawn args — only for temp profiles (no profilePath)
|
|
466
|
+
let tmpDir;
|
|
467
|
+
if (!this._profilePath) {
|
|
468
|
+
const tmpDirFlag = result.process.spawnargs.find((a) => a.startsWith("--user-data-dir="));
|
|
469
|
+
tmpDir = tmpDirFlag?.split("=")[1];
|
|
470
|
+
}
|
|
471
|
+
const connection = new ChromeConnection(result.cdpClient, result.transport, result.transportType, result.process, tmpDir, this, this._port, this._headless, this._profilePath);
|
|
472
|
+
debug("Connected via pipe");
|
|
473
|
+
return connection;
|
|
474
|
+
}
|
|
475
|
+
async _connectViaWebSocket(port) {
|
|
476
|
+
const versionInfo = await fetchJsonVersion(port);
|
|
477
|
+
if (!versionInfo.webSocketDebuggerUrl) {
|
|
478
|
+
throw new Error("/json/version response missing webSocketDebuggerUrl field");
|
|
479
|
+
}
|
|
480
|
+
const wsUrl = versionInfo.webSocketDebuggerUrl;
|
|
481
|
+
const transport = await WebSocketTransport.connect(wsUrl, {
|
|
482
|
+
timeoutMs: 5000,
|
|
483
|
+
});
|
|
484
|
+
const cdpClient = new CdpClient(transport);
|
|
485
|
+
// Verify connection
|
|
486
|
+
await cdpClient.send("Browser.getVersion");
|
|
487
|
+
// Auto-detect headless from /json/version Browser field.
|
|
488
|
+
// Headed Chrome reports "Chrome/...", headless reports "HeadlessChrome/...".
|
|
489
|
+
const browserString = typeof versionInfo.Browser === "string" ? versionInfo.Browser : "";
|
|
490
|
+
const detectedHeadless = browserString.includes("HeadlessChrome");
|
|
491
|
+
if (detectedHeadless !== this._headless) {
|
|
492
|
+
debug("Headless auto-detected=%s (Browser: %s), overriding env setting=%s", detectedHeadless, browserString, this._headless);
|
|
493
|
+
}
|
|
494
|
+
if (this._profilePath) {
|
|
495
|
+
debug("Connected via WebSocket to existing Chrome — profilePath ignored (only affects Auto-Launch)");
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
debug("Connected via WebSocket");
|
|
499
|
+
}
|
|
500
|
+
return new ChromeConnection(cdpClient, transport, "websocket", undefined, undefined, this, port, detectedHeadless);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CdpClient } from "./cdp-client.js";
|
|
2
|
+
export interface ConsoleLogEntry {
|
|
3
|
+
level: "info" | "warning" | "error" | "debug";
|
|
4
|
+
text: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
source: "console" | "exception";
|
|
7
|
+
}
|
|
8
|
+
export interface ConsoleCollectorOptions {
|
|
9
|
+
maxEntries?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class ConsoleCollector {
|
|
12
|
+
private _buffer;
|
|
13
|
+
private _maxEntries;
|
|
14
|
+
private _cdpClient;
|
|
15
|
+
private _sessionId;
|
|
16
|
+
private _consoleCallback;
|
|
17
|
+
private _exceptionCallback;
|
|
18
|
+
private _initialized;
|
|
19
|
+
constructor(cdpClient: CdpClient, sessionId: string, options?: ConsoleCollectorOptions);
|
|
20
|
+
/**
|
|
21
|
+
* Start listening for Runtime.consoleAPICalled and Runtime.exceptionThrown events.
|
|
22
|
+
*/
|
|
23
|
+
init(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Remove event listeners. Buffer is preserved.
|
|
26
|
+
*/
|
|
27
|
+
detach(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Re-initialize after reconnect or tab switch.
|
|
30
|
+
* Buffer is cleared on reinit (new page context = new logs).
|
|
31
|
+
*/
|
|
32
|
+
reinit(cdpClient: CdpClient, sessionId: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Return a copy of all buffered log entries.
|
|
35
|
+
*/
|
|
36
|
+
getAll(): ConsoleLogEntry[];
|
|
37
|
+
/**
|
|
38
|
+
* Return filtered log entries. Both filters are combined with AND.
|
|
39
|
+
* Throws if the regex pattern is invalid.
|
|
40
|
+
*/
|
|
41
|
+
getFiltered(level?: string, pattern?: string): ConsoleLogEntry[];
|
|
42
|
+
/**
|
|
43
|
+
* Clear the log buffer.
|
|
44
|
+
*/
|
|
45
|
+
clear(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Current number of entries in the buffer.
|
|
48
|
+
*/
|
|
49
|
+
get count(): number;
|
|
50
|
+
private _pushEntry;
|
|
51
|
+
private _onConsoleAPICalled;
|
|
52
|
+
private _onExceptionThrown;
|
|
53
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { debug } from "./debug.js";
|
|
2
|
+
// --- Level Mapping ---
|
|
3
|
+
const LEVEL_MAP = {
|
|
4
|
+
log: "info",
|
|
5
|
+
info: "info",
|
|
6
|
+
debug: "debug",
|
|
7
|
+
warning: "warning",
|
|
8
|
+
error: "error",
|
|
9
|
+
assert: "error",
|
|
10
|
+
trace: "info",
|
|
11
|
+
};
|
|
12
|
+
function mapLevel(type) {
|
|
13
|
+
return LEVEL_MAP[type] ?? "info";
|
|
14
|
+
}
|
|
15
|
+
function remoteObjectToString(obj) {
|
|
16
|
+
if (obj.value !== undefined)
|
|
17
|
+
return String(obj.value);
|
|
18
|
+
if (obj.unserializableValue)
|
|
19
|
+
return obj.unserializableValue;
|
|
20
|
+
if (obj.description)
|
|
21
|
+
return obj.description;
|
|
22
|
+
return String(obj.type);
|
|
23
|
+
}
|
|
24
|
+
// --- ConsoleCollector ---
|
|
25
|
+
export class ConsoleCollector {
|
|
26
|
+
_buffer = [];
|
|
27
|
+
_maxEntries;
|
|
28
|
+
_cdpClient;
|
|
29
|
+
_sessionId;
|
|
30
|
+
_consoleCallback = null;
|
|
31
|
+
_exceptionCallback = null;
|
|
32
|
+
_initialized = false;
|
|
33
|
+
constructor(cdpClient, sessionId, options) {
|
|
34
|
+
this._cdpClient = cdpClient;
|
|
35
|
+
this._sessionId = sessionId;
|
|
36
|
+
this._maxEntries = options?.maxEntries ?? 1000;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start listening for Runtime.consoleAPICalled and Runtime.exceptionThrown events.
|
|
40
|
+
*/
|
|
41
|
+
init() {
|
|
42
|
+
if (this._initialized)
|
|
43
|
+
return;
|
|
44
|
+
this._initialized = true;
|
|
45
|
+
this._consoleCallback = (params) => {
|
|
46
|
+
this._onConsoleAPICalled(params);
|
|
47
|
+
};
|
|
48
|
+
this._exceptionCallback = (params) => {
|
|
49
|
+
this._onExceptionThrown(params);
|
|
50
|
+
};
|
|
51
|
+
this._cdpClient.on("Runtime.consoleAPICalled", this._consoleCallback, this._sessionId);
|
|
52
|
+
this._cdpClient.on("Runtime.exceptionThrown", this._exceptionCallback, this._sessionId);
|
|
53
|
+
debug("ConsoleCollector initialized on session %s", this._sessionId);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Remove event listeners. Buffer is preserved.
|
|
57
|
+
*/
|
|
58
|
+
detach() {
|
|
59
|
+
this._initialized = false;
|
|
60
|
+
if (this._consoleCallback) {
|
|
61
|
+
this._cdpClient.off("Runtime.consoleAPICalled", this._consoleCallback);
|
|
62
|
+
this._consoleCallback = null;
|
|
63
|
+
}
|
|
64
|
+
if (this._exceptionCallback) {
|
|
65
|
+
this._cdpClient.off("Runtime.exceptionThrown", this._exceptionCallback);
|
|
66
|
+
this._exceptionCallback = null;
|
|
67
|
+
}
|
|
68
|
+
debug("ConsoleCollector detached");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Re-initialize after reconnect or tab switch.
|
|
72
|
+
* Buffer is cleared on reinit (new page context = new logs).
|
|
73
|
+
*/
|
|
74
|
+
reinit(cdpClient, sessionId) {
|
|
75
|
+
this.detach();
|
|
76
|
+
this._cdpClient = cdpClient;
|
|
77
|
+
this._sessionId = sessionId;
|
|
78
|
+
this._buffer = [];
|
|
79
|
+
this.init();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Return a copy of all buffered log entries.
|
|
83
|
+
*/
|
|
84
|
+
getAll() {
|
|
85
|
+
return [...this._buffer];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Return filtered log entries. Both filters are combined with AND.
|
|
89
|
+
* Throws if the regex pattern is invalid.
|
|
90
|
+
*/
|
|
91
|
+
getFiltered(level, pattern) {
|
|
92
|
+
if (!level && !pattern)
|
|
93
|
+
return this.getAll();
|
|
94
|
+
let regex;
|
|
95
|
+
if (pattern) {
|
|
96
|
+
regex = new RegExp(pattern);
|
|
97
|
+
}
|
|
98
|
+
return this._buffer.filter((entry) => {
|
|
99
|
+
if (level && entry.level !== level)
|
|
100
|
+
return false;
|
|
101
|
+
if (regex && !regex.test(entry.text))
|
|
102
|
+
return false;
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Clear the log buffer.
|
|
108
|
+
*/
|
|
109
|
+
clear() {
|
|
110
|
+
this._buffer = [];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Current number of entries in the buffer.
|
|
114
|
+
*/
|
|
115
|
+
get count() {
|
|
116
|
+
return this._buffer.length;
|
|
117
|
+
}
|
|
118
|
+
// --- Internal ---
|
|
119
|
+
_pushEntry(entry) {
|
|
120
|
+
if (this._buffer.length >= this._maxEntries) {
|
|
121
|
+
this._buffer.shift();
|
|
122
|
+
}
|
|
123
|
+
this._buffer.push(entry);
|
|
124
|
+
}
|
|
125
|
+
_onConsoleAPICalled(params) {
|
|
126
|
+
const p = params;
|
|
127
|
+
const text = (p.args ?? []).map(remoteObjectToString).join(" ");
|
|
128
|
+
this._pushEntry({
|
|
129
|
+
level: mapLevel(p.type ?? "log"),
|
|
130
|
+
text,
|
|
131
|
+
timestamp: performance.now(),
|
|
132
|
+
source: "console",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
_onExceptionThrown(params) {
|
|
136
|
+
const p = params;
|
|
137
|
+
const text = p.exceptionDetails?.exception?.description ??
|
|
138
|
+
p.exceptionDetails?.text ??
|
|
139
|
+
"Unknown exception";
|
|
140
|
+
this._pushEntry({
|
|
141
|
+
level: "error",
|
|
142
|
+
text,
|
|
143
|
+
timestamp: performance.now(),
|
|
144
|
+
source: "exception",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|