browser-debugging-daemon 1.0.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.
@@ -0,0 +1,1706 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { spawn, spawnSync } from "child_process";
4
+ import { chromium } from "playwright";
5
+ import {
6
+ clearOverlays,
7
+ collectInteractableElements,
8
+ collectPageContent,
9
+ getSelectAllShortcut,
10
+ loadStorageState,
11
+ refreshTarget,
12
+ saveBrowserState,
13
+ writeBase64Files,
14
+ } from "../shared.js";
15
+ import { ArtifactStore } from "./ArtifactStore.js";
16
+
17
+ const SUPPORTED_BROWSER_SOURCES = new Set(["auto", "managed", "attached"]);
18
+ const DEFAULT_TEXT_AUDIT_SELECTORS = [
19
+ "button",
20
+ "a",
21
+ "label",
22
+ "input[type='button']",
23
+ "input[type='submit']",
24
+ "input[type='reset']",
25
+ "[role='button']",
26
+ "[role='link']",
27
+ "[data-testid]",
28
+ ].join(", ");
29
+ const ATTACHED_SCREENCAST_FPS = 10;
30
+ const ATTACHED_SCREENCAST_MAX_PENDING_WRITES = Number.parseInt(process.env.BROWSER_ATTACHED_SCREENCAST_MAX_PENDING_WRITES, 10) || 80;
31
+
32
+ function trimLogBuffer(buffer, limit = 200) {
33
+ if (buffer.length <= limit) return buffer;
34
+ return buffer.slice(buffer.length - limit);
35
+ }
36
+
37
+ function normalizeBrowserSource(source) {
38
+ const candidate = typeof source === "string" ? source.trim().toLowerCase() : "";
39
+ if (SUPPORTED_BROWSER_SOURCES.has(candidate)) {
40
+ return candidate;
41
+ }
42
+ return "auto";
43
+ }
44
+
45
+ export class BrowserRuntime {
46
+ constructor(baseDir) {
47
+ this.baseDir = baseDir;
48
+ this.storageStatePath = path.join(baseDir, "storage_state.json");
49
+ this.artifacts = new ArtifactStore(baseDir);
50
+ this.lastSessionSummary = null;
51
+ this.requestedSource = "auto";
52
+ this.sessionSource = "managed";
53
+ this.cdpEndpoint = process.env.CHROME_REMOTE_DEBUG_URL || "http://127.0.0.1:9222";
54
+ this.autoFallbackReason = null;
55
+ this.browserMode = "stopped";
56
+ this.manualControlActive = false;
57
+ this.traceActive = false;
58
+ this.attachedVideoCapability = false;
59
+ this.attachedScreencast = null;
60
+ this.ffmpegAvailable = null;
61
+ this.resetRuntimeState();
62
+ }
63
+
64
+ resetRuntimeState() {
65
+ this.browser = null;
66
+ this.context = null;
67
+ this.page = null;
68
+ this.interactableElements = [];
69
+ this.consoleMessages = [];
70
+ this.networkEvents = [];
71
+ this.pageErrors = [];
72
+ this.requestedSource = "auto";
73
+ this.sessionSource = "managed";
74
+ this.autoFallbackReason = null;
75
+ this.browserMode = "stopped";
76
+ this.manualControlActive = false;
77
+ this.traceActive = false;
78
+ this.attachedVideoCapability = false;
79
+ this.attachedScreencast = null;
80
+ }
81
+
82
+ async ensureStarted(options = {}) {
83
+ if (!this.browser) {
84
+ await this.start(options);
85
+ }
86
+ }
87
+
88
+ async start(options = {}) {
89
+ if (this.browser) {
90
+ return {
91
+ alreadyRunning: true,
92
+ artifacts: this.artifacts.getSummary(),
93
+ requestedSource: this.requestedSource,
94
+ browserMode: this.browserMode,
95
+ sessionSource: this.sessionSource,
96
+ autoFallbackReason: this.autoFallbackReason,
97
+ };
98
+ }
99
+
100
+ this.artifacts.startSession();
101
+ this.requestedSource = normalizeBrowserSource(options.source || "auto");
102
+ this.sessionSource = this.requestedSource;
103
+ this.cdpEndpoint = options.cdpEndpoint || this.cdpEndpoint;
104
+ this.autoFallbackReason = null;
105
+
106
+ if (this.requestedSource === "attached") {
107
+ await this.attachBrowserSession({
108
+ endpoint: this.cdpEndpoint,
109
+ eventType: "session_attached",
110
+ });
111
+ } else if (this.requestedSource === "auto") {
112
+ try {
113
+ await this.attachBrowserSession({
114
+ endpoint: this.cdpEndpoint,
115
+ eventType: "session_auto_attached",
116
+ });
117
+ } catch (error) {
118
+ this.autoFallbackReason = error.message;
119
+ await this.cleanupBrokenSession();
120
+ this.sessionSource = "managed";
121
+ this.artifacts.appendEvent("session_auto_attach_failed", {
122
+ requestedSource: this.requestedSource,
123
+ endpoint: this.cdpEndpoint,
124
+ error: this.autoFallbackReason,
125
+ });
126
+ await this.launchBrowserSession({
127
+ headless: options.headless ?? true,
128
+ eventType: "session_auto_fallback_started",
129
+ });
130
+ }
131
+ } else {
132
+ this.sessionSource = "managed";
133
+ await this.launchBrowserSession({
134
+ headless: options.headless ?? true,
135
+ eventType: "session_started",
136
+ });
137
+ }
138
+
139
+ return {
140
+ alreadyRunning: false,
141
+ artifacts: this.artifacts.getSummary(),
142
+ requestedSource: this.requestedSource,
143
+ browserMode: this.browserMode,
144
+ sessionSource: this.sessionSource,
145
+ autoFallbackReason: this.autoFallbackReason,
146
+ };
147
+ }
148
+
149
+ async cleanupBrokenSession() {
150
+ await this.stopAttachedScreencast({
151
+ reason: "cleanup-broken-session",
152
+ persist: false,
153
+ }).catch(() => {});
154
+ if (this.browser) {
155
+ await this.browser.close().catch(() => {});
156
+ }
157
+ this.browser = null;
158
+ this.context = null;
159
+ this.page = null;
160
+ this.interactableElements = [];
161
+ this.browserMode = "stopped";
162
+ this.manualControlActive = false;
163
+ this.traceActive = false;
164
+ this.attachedVideoCapability = false;
165
+ }
166
+
167
+ async launchBrowserSession({ headless, navigateToUrl = null, eventType = "session_started" }) {
168
+ this.browser = await chromium.launch({
169
+ headless,
170
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
171
+ });
172
+
173
+ const contextOptions = {
174
+ viewport: { width: 1280, height: 800 },
175
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/121.0.0.0 Safari/537.36",
176
+ recordVideo: {
177
+ dir: this.artifacts.getVideoDir(),
178
+ size: { width: 1280, height: 800 },
179
+ },
180
+ };
181
+
182
+ const storageState = await loadStorageState(this.storageStatePath);
183
+ if (storageState) {
184
+ contextOptions.storageState = storageState;
185
+ }
186
+
187
+ this.context = await this.browser.newContext(contextOptions);
188
+ await this.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
189
+ this.traceActive = true;
190
+ this.page = await this.context.newPage();
191
+ this.attachPageObservers();
192
+ this.browserMode = headless ? "headless" : "headful";
193
+ this.manualControlActive = !headless;
194
+ this.interactableElements = [];
195
+
196
+ if (navigateToUrl && !navigateToUrl.startsWith("about:blank")) {
197
+ await this.page.goto(navigateToUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
198
+ await this.smartWait();
199
+ }
200
+
201
+ this.artifacts.appendEvent(eventType, {
202
+ reusedStorageState: Boolean(storageState),
203
+ requestedSource: this.requestedSource,
204
+ sessionSource: this.sessionSource,
205
+ browserMode: this.browserMode,
206
+ resumedUrl: navigateToUrl,
207
+ });
208
+ }
209
+
210
+ async attachBrowserSession({ endpoint, eventType = "session_attached" }) {
211
+ this.browser = await chromium.connectOverCDP(endpoint);
212
+ this.sessionSource = "attached";
213
+ this.browserMode = "attached";
214
+ this.manualControlActive = true;
215
+ this.interactableElements = [];
216
+
217
+ const contexts = this.browser.contexts();
218
+ this.context = contexts[0] || null;
219
+ if (!this.context) {
220
+ throw new Error(`No attachable browser context found at ${endpoint}. Open a Chrome tab after enabling remote debugging.`);
221
+ }
222
+
223
+ try {
224
+ await this.context.tracing.start({ screenshots: true, snapshots: true, sources: true });
225
+ this.traceActive = true;
226
+ } catch (error) {
227
+ this.traceActive = false;
228
+ this.artifacts.appendEvent("session_trace_unavailable", {
229
+ sessionSource: this.sessionSource,
230
+ browserMode: this.browserMode,
231
+ error: error.message,
232
+ });
233
+ }
234
+
235
+ const pages = this.context.pages();
236
+ this.page = pages[0] || await this.context.newPage();
237
+ this.attachPageObservers();
238
+ await this.startAttachedScreencast();
239
+
240
+ this.artifacts.appendEvent(eventType, {
241
+ requestedSource: this.requestedSource,
242
+ sessionSource: this.sessionSource,
243
+ browserMode: this.browserMode,
244
+ endpoint,
245
+ attachedPageUrl: this.page.url(),
246
+ });
247
+ }
248
+
249
+ detectFfmpegAvailability() {
250
+ if (typeof this.ffmpegAvailable === "boolean") {
251
+ return this.ffmpegAvailable;
252
+ }
253
+
254
+ try {
255
+ const result = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
256
+ this.ffmpegAvailable = result.status === 0;
257
+ } catch (error) {
258
+ this.ffmpegAvailable = false;
259
+ }
260
+
261
+ return this.ffmpegAvailable;
262
+ }
263
+
264
+ async encodeScreencastFramesToWebm({ inputPattern, outputPath, fps }) {
265
+ return new Promise((resolve, reject) => {
266
+ const args = [
267
+ "-y",
268
+ "-framerate",
269
+ String(fps),
270
+ "-i",
271
+ inputPattern,
272
+ "-c:v",
273
+ "libvpx-vp9",
274
+ "-pix_fmt",
275
+ "yuv420p",
276
+ outputPath,
277
+ ];
278
+
279
+ const child = spawn("ffmpeg", args, {
280
+ stdio: ["ignore", "pipe", "pipe"],
281
+ });
282
+
283
+ let stderr = "";
284
+ child.stderr.on("data", (chunk) => {
285
+ stderr += chunk.toString();
286
+ if (stderr.length > 4000) {
287
+ stderr = stderr.slice(-4000);
288
+ }
289
+ });
290
+
291
+ child.on("error", (error) => {
292
+ reject(new Error(`Failed to start ffmpeg: ${error.message}`));
293
+ });
294
+
295
+ child.on("close", (code) => {
296
+ if (code === 0) {
297
+ resolve(outputPath);
298
+ return;
299
+ }
300
+ reject(new Error(`ffmpeg exited with code ${code}: ${stderr.trim()}`));
301
+ });
302
+ });
303
+ }
304
+
305
+ async startAttachedScreencast() {
306
+ if (this.sessionSource !== "attached" || !this.context || !this.page) {
307
+ return;
308
+ }
309
+
310
+ if (this.attachedScreencast?.active) {
311
+ return;
312
+ }
313
+
314
+ if (!this.detectFfmpegAvailability()) {
315
+ this.attachedVideoCapability = false;
316
+ this.artifacts.appendEvent("session_attached_video_unavailable", {
317
+ reason: "ffmpeg_not_found",
318
+ hint: "Install ffmpeg to enable attached-mode video replay artifacts.",
319
+ });
320
+ return;
321
+ }
322
+
323
+ const framesDir = this.artifacts.getAttachedFramesDir("attached-screencast-frames");
324
+ const inputPattern = path.join(framesDir, "frame_%06d.jpg");
325
+
326
+ let cdpSession = null;
327
+ let onFrame = null;
328
+ try {
329
+ cdpSession = await this.context.newCDPSession(this.page);
330
+ const recorder = {
331
+ active: true,
332
+ cdpSession,
333
+ framesDir,
334
+ inputPattern,
335
+ frameCount: 0,
336
+ receivedFrames: 0,
337
+ droppedFrames: 0,
338
+ startedAt: Date.now(),
339
+ maxPendingWrites: ATTACHED_SCREENCAST_MAX_PENDING_WRITES,
340
+ pendingFrameWrites: new Set(),
341
+ onFrame: null,
342
+ };
343
+
344
+ recorder.onFrame = async (payload) => {
345
+ if (!recorder.active) return;
346
+ recorder.receivedFrames += 1;
347
+
348
+ if (recorder.pendingFrameWrites.size >= recorder.maxPendingWrites) {
349
+ recorder.droppedFrames += 1;
350
+ } else {
351
+ recorder.frameCount += 1;
352
+ const frameName = `frame_${String(recorder.frameCount).padStart(6, "0")}.jpg`;
353
+ const framePath = path.join(recorder.framesDir, frameName);
354
+ const writePromise = fs.promises
355
+ .writeFile(framePath, payload.data, "base64")
356
+ .catch(() => {
357
+ recorder.droppedFrames += 1;
358
+ })
359
+ .finally(() => {
360
+ recorder.pendingFrameWrites.delete(writePromise);
361
+ });
362
+ recorder.pendingFrameWrites.add(writePromise);
363
+ }
364
+
365
+ try {
366
+ await recorder.cdpSession.send("Page.screencastFrameAck", { sessionId: payload.sessionId });
367
+ } catch (error) {
368
+ // Best effort; ack failures are surfaced during stop.
369
+ }
370
+ };
371
+ onFrame = recorder.onFrame;
372
+
373
+ cdpSession.on("Page.screencastFrame", recorder.onFrame);
374
+ await cdpSession.send("Page.startScreencast", {
375
+ format: "jpeg",
376
+ quality: 75,
377
+ maxWidth: 1280,
378
+ maxHeight: 800,
379
+ everyNthFrame: 1,
380
+ });
381
+ this.attachedScreencast = recorder;
382
+ this.attachedVideoCapability = true;
383
+ this.artifacts.appendEvent("session_attached_video_started", {
384
+ framesDir,
385
+ fps: ATTACHED_SCREENCAST_FPS,
386
+ maxPendingWrites: recorder.maxPendingWrites,
387
+ });
388
+ } catch (error) {
389
+ if (cdpSession && onFrame) {
390
+ cdpSession.removeListener("Page.screencastFrame", onFrame);
391
+ }
392
+ if (cdpSession) {
393
+ await cdpSession.detach().catch(() => {});
394
+ }
395
+ this.attachedVideoCapability = false;
396
+ this.artifacts.appendEvent("session_attached_video_unavailable", {
397
+ reason: "cdp_start_failed",
398
+ error: error.message,
399
+ });
400
+ fs.rmSync(framesDir, { recursive: true, force: true });
401
+ }
402
+ }
403
+
404
+ async stopAttachedScreencast({ reason = "session-stop", persist = true } = {}) {
405
+ const recorder = this.attachedScreencast;
406
+ if (!recorder) {
407
+ return null;
408
+ }
409
+
410
+ this.attachedScreencast = null;
411
+ recorder.active = false;
412
+
413
+ try {
414
+ await recorder.cdpSession.send("Page.stopScreencast");
415
+ } catch (error) {
416
+ // Ignore stop errors; detach still runs.
417
+ }
418
+
419
+ recorder.cdpSession.removeListener("Page.screencastFrame", recorder.onFrame);
420
+ await recorder.cdpSession.detach().catch(() => {});
421
+ if (recorder.pendingFrameWrites.size > 0) {
422
+ await Promise.allSettled(Array.from(recorder.pendingFrameWrites));
423
+ }
424
+
425
+ if (!persist) {
426
+ fs.rmSync(recorder.framesDir, { recursive: true, force: true });
427
+ return null;
428
+ }
429
+
430
+ if (recorder.frameCount < 2) {
431
+ this.artifacts.appendEvent("session_attached_video_skipped", {
432
+ reason: "insufficient_frames",
433
+ frameCount: recorder.frameCount,
434
+ receivedFrames: recorder.receivedFrames,
435
+ droppedFrames: recorder.droppedFrames,
436
+ });
437
+ fs.rmSync(recorder.framesDir, { recursive: true, force: true });
438
+ return null;
439
+ }
440
+
441
+ const outputPath = this.artifacts.getVideoPath(`attached-${reason}`);
442
+ try {
443
+ await this.encodeScreencastFramesToWebm({
444
+ inputPattern: recorder.inputPattern,
445
+ outputPath,
446
+ fps: ATTACHED_SCREENCAST_FPS,
447
+ });
448
+ } catch (error) {
449
+ this.attachedVideoCapability = false;
450
+ this.artifacts.appendEvent("session_attached_video_failed", {
451
+ error: error.message,
452
+ frameCount: recorder.frameCount,
453
+ receivedFrames: recorder.receivedFrames,
454
+ droppedFrames: recorder.droppedFrames,
455
+ framesDir: recorder.framesDir,
456
+ });
457
+ return null;
458
+ }
459
+
460
+ this.artifacts.appendEvent("session_attached_video_saved", {
461
+ videoPath: outputPath,
462
+ frameCount: recorder.frameCount,
463
+ receivedFrames: recorder.receivedFrames,
464
+ droppedFrames: recorder.droppedFrames,
465
+ durationMs: Math.max(0, Date.now() - recorder.startedAt),
466
+ fps: ATTACHED_SCREENCAST_FPS,
467
+ });
468
+ fs.rmSync(recorder.framesDir, { recursive: true, force: true });
469
+ return outputPath;
470
+ }
471
+
472
+ async finalizeTraceSegment(label) {
473
+ if (!this.context || !this.traceActive) {
474
+ return null;
475
+ }
476
+
477
+ const tracePath = this.artifacts.getTracePath(label);
478
+ await this.context.tracing.stop({ path: tracePath });
479
+ this.traceActive = false;
480
+ return tracePath;
481
+ }
482
+
483
+ async switchBrowserMode(targetMode, reason = "mode_switch") {
484
+ await this.ensureStarted();
485
+ if (this.sessionSource === "attached") {
486
+ return {
487
+ changed: false,
488
+ browserMode: this.browserMode,
489
+ sessionSource: this.sessionSource,
490
+ artifacts: this.artifacts.getSummary(),
491
+ };
492
+ }
493
+ if (this.browserMode === targetMode) {
494
+ return {
495
+ changed: false,
496
+ browserMode: this.browserMode,
497
+ sessionSource: this.sessionSource,
498
+ artifacts: this.artifacts.getSummary(),
499
+ };
500
+ }
501
+
502
+ const resumeUrl = this.page?.url?.() || null;
503
+ const previousMode = this.browserMode;
504
+ this.artifacts.appendEvent("session_switch_requested", {
505
+ from: previousMode,
506
+ to: targetMode,
507
+ reason,
508
+ resumeUrl,
509
+ });
510
+
511
+ await saveBrowserState(this.context, this.storageStatePath);
512
+ await this.finalizeTraceSegment(`${reason}-${previousMode}`);
513
+ await this.browser.close();
514
+ this.browser = null;
515
+ this.context = null;
516
+ this.page = null;
517
+ this.interactableElements = [];
518
+
519
+ try {
520
+ await this.launchBrowserSession({
521
+ headless: targetMode !== "headful",
522
+ navigateToUrl: resumeUrl,
523
+ eventType: "session_switched",
524
+ });
525
+ } catch (error) {
526
+ this.artifacts.appendEvent("session_switch_failed", {
527
+ from: previousMode,
528
+ to: targetMode,
529
+ reason,
530
+ error: error.message,
531
+ });
532
+
533
+ try {
534
+ await this.launchBrowserSession({
535
+ headless: previousMode !== "headful",
536
+ navigateToUrl: resumeUrl,
537
+ eventType: "session_switch_recovered",
538
+ });
539
+ } catch (recoveryError) {
540
+ this.artifacts.appendEvent("session_switch_recovery_failed", {
541
+ from: previousMode,
542
+ to: targetMode,
543
+ reason,
544
+ error: recoveryError.message,
545
+ });
546
+ throw new Error(`Failed to switch browser mode to ${targetMode}: ${error.message}. Recovery also failed: ${recoveryError.message}`);
547
+ }
548
+
549
+ throw new Error(`Failed to switch browser mode to ${targetMode}: ${error.message}. Restored ${previousMode} mode instead.`);
550
+ }
551
+
552
+ this.artifacts.appendEvent("session_switch_completed", {
553
+ from: previousMode,
554
+ to: this.browserMode,
555
+ reason,
556
+ resumedUrl: this.page?.url?.() || resumeUrl,
557
+ });
558
+
559
+ return {
560
+ changed: true,
561
+ browserMode: this.browserMode,
562
+ sessionSource: this.sessionSource,
563
+ resumedUrl: this.page?.url?.() || resumeUrl,
564
+ artifacts: this.artifacts.getSummary(),
565
+ };
566
+ }
567
+
568
+ async enterManualControl() {
569
+ return this.switchBrowserMode("headful", "manual_control");
570
+ }
571
+
572
+ async exitManualControl() {
573
+ return this.switchBrowserMode("headless", "resume_automation");
574
+ }
575
+
576
+ attachPageObservers() {
577
+ this.page.on("console", (msg) => {
578
+ const location = msg.location();
579
+ const entry = {
580
+ type: msg.type(),
581
+ text: msg.text(),
582
+ location: location?.url ? {
583
+ url: location.url,
584
+ lineNumber: location.lineNumber,
585
+ columnNumber: location.columnNumber,
586
+ } : null,
587
+ };
588
+ this.consoleMessages = trimLogBuffer([...this.consoleMessages, entry]);
589
+ });
590
+
591
+ this.page.on("pageerror", (error) => {
592
+ const entry = {
593
+ message: error.message,
594
+ stack: error.stack || null,
595
+ };
596
+ this.pageErrors = trimLogBuffer([...this.pageErrors, entry]);
597
+ });
598
+
599
+ this.page.on("requestfinished", async (request) => {
600
+ const response = await request.response().catch(() => null);
601
+ const entry = {
602
+ type: "requestfinished",
603
+ url: request.url(),
604
+ method: request.method(),
605
+ resourceType: request.resourceType(),
606
+ status: response?.status() ?? null,
607
+ ok: response?.ok() ?? null,
608
+ };
609
+
610
+ // Capture request body for non-GET requests
611
+ if (request.method() !== "GET") {
612
+ const postData = request.postData();
613
+ if (postData) {
614
+ entry.requestBody = postData.length > 4096
615
+ ? postData.slice(0, 4096) + `... (${postData.length} bytes total)`
616
+ : postData;
617
+ }
618
+ }
619
+
620
+ // Capture response body for relevant resource types
621
+ const captureTypes = new Set(["document", "xhr", "fetch"]);
622
+ if (response && captureTypes.has(request.resourceType())) {
623
+ try {
624
+ const body = await response.text().catch(() => null);
625
+ if (body !== null) {
626
+ entry.responseBody = body.length > 4096
627
+ ? body.slice(0, 4096) + `... (${body.length} bytes total)`
628
+ : body;
629
+ entry.responseBodyTruncated = body.length > 4096;
630
+ }
631
+ } catch (_) {
632
+ // Body not available for some responses (e.g. redirects)
633
+ }
634
+ }
635
+
636
+ this.networkEvents = trimLogBuffer([...this.networkEvents, entry]);
637
+ });
638
+
639
+ this.page.on("requestfailed", (request) => {
640
+ const entry = {
641
+ type: "requestfailed",
642
+ url: request.url(),
643
+ method: request.method(),
644
+ resourceType: request.resourceType(),
645
+ failure: request.failure()?.errorText ?? "unknown",
646
+ };
647
+
648
+ if (request.method() !== "GET") {
649
+ const postData = request.postData();
650
+ if (postData) {
651
+ entry.requestBody = postData.length > 4096
652
+ ? postData.slice(0, 4096) + `... (${postData.length} bytes total)`
653
+ : postData;
654
+ }
655
+ }
656
+
657
+ this.networkEvents = trimLogBuffer([...this.networkEvents, entry]);
658
+ });
659
+ }
660
+
661
+ async smartWait() {
662
+ if (!this.page) return;
663
+
664
+ try {
665
+ await this.page.waitForLoadState("networkidle", { timeout: 1500 }).catch(() => { });
666
+ } catch (error) {
667
+ // Best-effort wait for apps that keep a connection open.
668
+ }
669
+
670
+ await this.page.waitForTimeout(500);
671
+ }
672
+
673
+ async captureStepScreenshot(label, { fullPage = false } = {}) {
674
+ if (!this.page) return null;
675
+
676
+ const screenshotPath = this.artifacts.getStepScreenshotPath(label);
677
+ await this.page.screenshot({ path: screenshotPath, fullPage });
678
+ return screenshotPath;
679
+ }
680
+
681
+ async goto(url) {
682
+ await this.ensureStarted();
683
+ await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
684
+ await this.smartWait();
685
+
686
+ // Auto-observe: collect elements and page content so the caller gets a full picture
687
+ this.interactableElements = await collectInteractableElements(this.page, { drawOverlays: true });
688
+ const screenshotPath = this.artifacts.getCurrentViewPath();
689
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
690
+ await clearOverlays(this.page);
691
+
692
+ // Accessibility Tree
693
+ let accessibilityTree = null;
694
+ try {
695
+ accessibilityTree = await this.page.locator(":root").ariaSnapshot({ timeout: 5000 });
696
+ } catch (_) {}
697
+
698
+ const pageContent = await collectPageContent(this.page);
699
+ const metadata = await this.getPageMetadata(500);
700
+
701
+ this.artifacts.appendEvent("goto", {
702
+ url,
703
+ elementCount: this.interactableElements.length,
704
+ hasA11yTree: accessibilityTree !== null,
705
+ screenshotPath,
706
+ });
707
+
708
+ return {
709
+ url,
710
+ metadata,
711
+ elements: this.interactableElements,
712
+ accessibilityTree,
713
+ pageContent,
714
+ screenshotPath,
715
+ };
716
+ }
717
+
718
+ async observe() {
719
+ await this.ensureStarted();
720
+ this.interactableElements = await collectInteractableElements(this.page, { drawOverlays: true });
721
+
722
+ const screenshotPath = this.artifacts.getCurrentViewPath();
723
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
724
+ await clearOverlays(this.page);
725
+
726
+ // Accessibility Tree via Playwright's ariaSnapshot (YAML string)
727
+ let accessibilityTree = null;
728
+ try {
729
+ accessibilityTree = await this.page.locator(":root").ariaSnapshot({ timeout: 5000 });
730
+ } catch (_) {
731
+ // ariaSnapshot may fail on some pages (e.g. about:blank)
732
+ }
733
+
734
+ const pageContent = await collectPageContent(this.page);
735
+ const recentErrors = this.pageErrors.filter((e) => e.message).slice(-5);
736
+
737
+ this.artifacts.appendEvent("observe", {
738
+ count: this.interactableElements.length,
739
+ hasA11yTree: accessibilityTree !== null,
740
+ screenshotPath,
741
+ });
742
+
743
+ return {
744
+ elements: this.interactableElements,
745
+ accessibilityTree,
746
+ pageContent,
747
+ recentErrors: recentErrors.length > 0 ? recentErrors : undefined,
748
+ screenshotPath,
749
+ };
750
+ }
751
+
752
+ async resolveTarget(id) {
753
+ await this.ensureStarted();
754
+ const target = await refreshTarget(this.page, this.interactableElements, id);
755
+ this.interactableElements = this.interactableElements.map((element) => element.id === id ? target : element);
756
+ return target;
757
+ }
758
+
759
+ async click(id) {
760
+ const target = await this.resolveTarget(id);
761
+ await this.page.mouse.click(target.x, target.y);
762
+ await this.smartWait();
763
+ const screenshotPath = await this.captureStepScreenshot(`click-${id}`);
764
+ this.artifacts.appendEvent("click", { id, target, screenshotPath });
765
+ return { id, target, screenshotPath };
766
+ }
767
+
768
+ async type(id, text, { submit = false } = {}) {
769
+ const target = await this.resolveTarget(id);
770
+ await this.page.mouse.click(target.x, target.y);
771
+ await this.page.keyboard.press(getSelectAllShortcut());
772
+ await this.page.keyboard.press("Backspace");
773
+ await this.page.waitForTimeout(100);
774
+ await this.page.keyboard.type(text);
775
+ if (submit) {
776
+ await this.page.keyboard.press("Enter");
777
+ }
778
+ await this.smartWait();
779
+ const screenshotPath = await this.captureStepScreenshot(`type-${id}`);
780
+ this.artifacts.appendEvent("type", { id, text, submit, target, screenshotPath });
781
+ return { id, target, text, submit, screenshotPath };
782
+ }
783
+
784
+ async hover(id) {
785
+ const target = await this.resolveTarget(id);
786
+ await this.page.mouse.move(target.x, target.y);
787
+ await this.smartWait();
788
+ const screenshotPath = await this.captureStepScreenshot(`hover-${id}`);
789
+ this.artifacts.appendEvent("hover", { id, target, screenshotPath });
790
+ return { id, target, screenshotPath };
791
+ }
792
+
793
+ // --- New Playwright-parity methods ---
794
+
795
+ async evaluate(expression) {
796
+ await this.ensureStarted();
797
+ let result;
798
+ let isError = false;
799
+ try {
800
+ const fn = new Function("page", `return (async () => { ${expression} })()`);
801
+ result = await fn(this.page);
802
+ } catch (error) {
803
+ result = error.message || String(error);
804
+ isError = true;
805
+ }
806
+ const serialized = typeof result === "object" && result !== null
807
+ ? JSON.stringify(result, null, 2).slice(0, 8192)
808
+ : String(result ?? "").slice(0, 8192);
809
+ this.artifacts.appendEvent("evaluate", { isError, length: serialized.length });
810
+ return { result: serialized, isError };
811
+ }
812
+
813
+ async selectOption(id, values) {
814
+ const target = await this.resolveTarget(id);
815
+ // Find the <select> element at or near the SoM target
816
+ const selectHandle = await this.page.evaluateHandle((targetInfo) => {
817
+ const elements = document.querySelectorAll("select");
818
+ // Check if the target itself is a select at the same position
819
+ for (const el of elements) {
820
+ const rect = el.getBoundingClientRect();
821
+ const cx = rect.x + rect.width / 2;
822
+ const cy = rect.y + rect.height / 2;
823
+ if (Math.abs(cx - targetInfo.x) < 10 && Math.abs(cy - targetInfo.y) < 10) {
824
+ return el;
825
+ }
826
+ }
827
+ // Fallback: closest visible select
828
+ for (const el of elements) {
829
+ const rect = el.getBoundingClientRect();
830
+ if (rect.width > 0 && rect.height > 0) return el;
831
+ }
832
+ return null;
833
+ }, { x: target.x, y: target.y });
834
+
835
+ if (!selectHandle) {
836
+ throw new Error(`No <select> element found near element ${id}.`);
837
+ }
838
+
839
+ const valueArray = Array.isArray(values) ? values : [values];
840
+ await selectHandle.selectOption(valueArray);
841
+ await this.smartWait();
842
+ const screenshotPath = await this.captureStepScreenshot(`select-${id}`);
843
+ this.artifacts.appendEvent("selectOption", { id, values: valueArray, target, screenshotPath });
844
+ return { id, values: valueArray, target, screenshotPath };
845
+ }
846
+
847
+ async handleDialog({ action = "accept", promptText = "" } = {}) {
848
+ await this.ensureStarted();
849
+ // Wait for a dialog to appear (or check if one is already being handled)
850
+ const dialogPromise = new Promise((resolve) => {
851
+ const handler = async (dialog) => {
852
+ this.page.off("dialog", handler);
853
+ const info = {
854
+ type: dialog.type(),
855
+ message: dialog.message(),
856
+ defaultValue: dialog.defaultValue(),
857
+ };
858
+ if (action === "dismiss") {
859
+ await dialog.dismiss();
860
+ } else {
861
+ await dialog.accept(promptText || undefined);
862
+ }
863
+ resolve(info);
864
+ };
865
+ this.page.on("dialog", handler);
866
+ // Timeout: if no dialog appears within 3s, remove handler and return null
867
+ setTimeout(() => {
868
+ this.page.off("dialog", handler);
869
+ resolve(null);
870
+ }, 3000);
871
+ });
872
+
873
+ const dialogInfo = await dialogPromise;
874
+ if (!dialogInfo) {
875
+ return { handled: false, message: "No dialog appeared within 3 seconds." };
876
+ }
877
+
878
+ const screenshotPath = await this.captureStepScreenshot(`dialog-${action}`);
879
+ this.artifacts.appendEvent("handleDialog", { action, dialogInfo, screenshotPath });
880
+ return { handled: true, action, dialogInfo, screenshotPath };
881
+ }
882
+
883
+ async goBack() {
884
+ await this.ensureStarted();
885
+ await this.page.goBack({ waitUntil: "domcontentloaded", timeout: 15000 });
886
+ await this.smartWait();
887
+ const screenshotPath = await this.captureStepScreenshot("goback");
888
+ this.artifacts.appendEvent("goBack", { url: this.page.url(), screenshotPath });
889
+ return { url: this.page.url(), screenshotPath };
890
+ }
891
+
892
+ async tabAction(action, { index = null, url = null } = {}) {
893
+ await this.ensureStarted();
894
+
895
+ if (action === "list") {
896
+ const pages = this.context.pages();
897
+ const currentPageIndex = pages.indexOf(this.page);
898
+ const tabs = [];
899
+ for (let i = 0; i < pages.length; i++) {
900
+ const p = pages[i];
901
+ const title = await p.title().catch(() => "");
902
+ tabs.push({ index: i, url: p.url(), title, active: i === currentPageIndex });
903
+ }
904
+ return { tabs };
905
+ }
906
+
907
+ if (action === "new") {
908
+ const newPage = await this.context.newPage();
909
+ if (url) {
910
+ await newPage.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => {});
911
+ }
912
+ // Switch to new page
913
+ this.page.off("console", this._consoleHandler);
914
+ this.page.off("pageerror", this._pageErrorHandler);
915
+ this.page.off("requestfinished", this._requestFinishedHandler);
916
+ this.page.off("requestfailed", this._requestFailedHandler);
917
+ this.page = newPage;
918
+ this.attachPageObservers();
919
+ this.interactableElements = [];
920
+ const screenshotPath = await this.captureStepScreenshot("tab-new");
921
+ return { url: this.page.url(), index: this.context.pages().length - 1, screenshotPath };
922
+ }
923
+
924
+ if (action === "close") {
925
+ const pages = this.context.pages();
926
+ const targetIndex = index != null ? index : pages.indexOf(this.page);
927
+ const targetPage = pages[targetIndex];
928
+ if (!targetPage) {
929
+ throw new Error(`Tab index ${targetIndex} not found.`);
930
+ }
931
+ await targetPage.close();
932
+
933
+ // If we closed the active tab, switch to the last remaining
934
+ if (targetPage === this.page) {
935
+ const remaining = this.context.pages();
936
+ if (remaining.length > 0) {
937
+ this.page = remaining[remaining.length - 1];
938
+ this.attachPageObservers();
939
+ } else {
940
+ this.page = null;
941
+ }
942
+ }
943
+ this.interactableElements = [];
944
+ return { closed: targetIndex, remaining: this.context.pages().length };
945
+ }
946
+
947
+ if (action === "select") {
948
+ const pages = this.context.pages();
949
+ const targetIndex = index != null ? index : pages.length - 1;
950
+ const targetPage = pages[targetIndex];
951
+ if (!targetPage) {
952
+ throw new Error(`Tab index ${targetIndex} not found.`);
953
+ }
954
+ if (targetPage === this.page) {
955
+ return { active: targetIndex, url: this.page.url() };
956
+ }
957
+ this.page = targetPage;
958
+ this.attachPageObservers();
959
+ this.interactableElements = [];
960
+ const screenshotPath = await this.captureStepScreenshot(`tab-select-${targetIndex}`);
961
+ return { active: targetIndex, url: this.page.url(), screenshotPath };
962
+ }
963
+
964
+ throw new Error(`Unknown tab action: ${action}. Use: list, new, close, select.`);
965
+ }
966
+
967
+ async keypress(key) {
968
+ await this.ensureStarted();
969
+ await this.page.keyboard.press(key);
970
+ await this.smartWait();
971
+ const screenshotPath = await this.captureStepScreenshot(`keypress-${key}`);
972
+ this.artifacts.appendEvent("keypress", { key, screenshotPath });
973
+ return { key, screenshotPath };
974
+ }
975
+
976
+ async scroll(direction) {
977
+ await this.ensureStarted();
978
+ await this.page.evaluate((dir) => {
979
+ const h = window.innerHeight;
980
+ if (dir === "down") window.scrollBy({ top: h * 0.8, behavior: "smooth" });
981
+ else if (dir === "up") window.scrollBy({ top: -h * 0.8, behavior: "smooth" });
982
+ else if (dir === "top") window.scrollTo({ top: 0, behavior: "smooth" });
983
+ else if (dir === "bottom") window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
984
+ }, direction);
985
+ await this.smartWait();
986
+ const screenshotPath = await this.captureStepScreenshot(`scroll-${direction}`);
987
+ this.artifacts.appendEvent("scroll", { direction, screenshotPath });
988
+ return { direction, screenshotPath };
989
+ }
990
+
991
+ async waitFor({ text, textGone, selector, timeout = 30000 } = {}) {
992
+ await this.ensureStarted();
993
+
994
+ const conditions = [];
995
+ if (text) conditions.push("text");
996
+ if (textGone) conditions.push("textGone");
997
+ if (selector) conditions.push("selector");
998
+ if (conditions.length === 0) {
999
+ throw new Error("waitFor requires at least one of: text, textGone, selector.");
1000
+ }
1001
+
1002
+ const startedAt = Date.now();
1003
+ const timeoutMs = Math.max(1000, Math.min(timeout, 300000));
1004
+ const pollIntervalMs = 500;
1005
+ const result = { matched: null, waitedMs: 0, condition: conditions[0] };
1006
+
1007
+ try {
1008
+ if (text) {
1009
+ await this.page.waitForFunction(
1010
+ (expected) => {
1011
+ const bodyText = document.body?.innerText || "";
1012
+ if (bodyText.includes(expected)) return true;
1013
+ const ph = "place" + "holder";
1014
+ const checkAttrs = [ph, "aria-label", "title", "data-testid"];
1015
+ for (const attr of checkAttrs) {
1016
+ for (const el of document.querySelectorAll(`[${attr}]`)) {
1017
+ const val = el.getAttribute(attr) || "";
1018
+ if (val.includes(expected)) return true;
1019
+ }
1020
+ }
1021
+ for (const el of document.querySelectorAll("input, textarea")) {
1022
+ const val = typeof el.value === "string" ? el.value : "";
1023
+ if (val.includes(expected)) return true;
1024
+ }
1025
+ return false;
1026
+ },
1027
+ text,
1028
+ { timeout: timeoutMs, polling: pollIntervalMs }
1029
+ );
1030
+ result.matched = "text";
1031
+ }
1032
+
1033
+ if (textGone) {
1034
+ await this.page.waitForFunction(
1035
+ (expected) => {
1036
+ const bodyText = document.body?.innerText || "";
1037
+ if (bodyText.includes(expected)) return false;
1038
+ const ph = "place" + "holder";
1039
+ const checkAttrs = [ph, "aria-label", "title", "data-testid"];
1040
+ for (const attr of checkAttrs) {
1041
+ for (const el of document.querySelectorAll(`[${attr}]`)) {
1042
+ const val = el.getAttribute(attr) || "";
1043
+ if (val.includes(expected)) return false;
1044
+ }
1045
+ }
1046
+ for (const el of document.querySelectorAll("input, textarea")) {
1047
+ const val = typeof el.value === "string" ? el.value : "";
1048
+ if (val.includes(expected)) return false;
1049
+ }
1050
+ return true;
1051
+ },
1052
+ textGone,
1053
+ { timeout: timeoutMs, polling: pollIntervalMs }
1054
+ );
1055
+ result.matched = "textGone";
1056
+ }
1057
+
1058
+ if (selector) {
1059
+ await this.page.waitForSelector(selector, { timeout: timeoutMs, state: "visible" });
1060
+ result.matched = "selector";
1061
+ }
1062
+ } catch (error) {
1063
+ result.matched = null;
1064
+ result.timedOut = true;
1065
+ result.error = error.message;
1066
+ }
1067
+
1068
+ result.waitedMs = Date.now() - startedAt;
1069
+ const screenshotPath = await this.captureStepScreenshot(`waitfor-${result.matched || "timeout"}`);
1070
+ this.artifacts.appendEvent("waitFor", { ...result, screenshotPath });
1071
+ return { ...result, screenshotPath };
1072
+ }
1073
+
1074
+ async upload(id, { paths = [], files = [] } = {}) {
1075
+ const target = await this.resolveTarget(id);
1076
+ const base64Paths = files.length > 0 ? writeBase64Files(files) : [];
1077
+ const allPaths = [...paths, ...base64Paths];
1078
+
1079
+ if (allPaths.length === 0) {
1080
+ throw new Error("No files provided. Supply 'paths' (local file paths) or 'files' (base64-encoded file objects).");
1081
+ }
1082
+
1083
+ for (const filePath of allPaths) {
1084
+ if (!fs.existsSync(filePath)) {
1085
+ throw new Error(`File not found: ${filePath}`);
1086
+ }
1087
+ }
1088
+
1089
+ // Find the file input element near the resolved target
1090
+ const fileInput = await this.page.evaluateHandle((targetInfo) => {
1091
+ const elements = document.querySelectorAll('input[type="file"]');
1092
+ if (targetInfo.tag === "input" && targetInfo.type === "file") {
1093
+ // The target itself is a file input — find it by position
1094
+ for (const el of elements) {
1095
+ const rect = el.getBoundingClientRect();
1096
+ const cx = rect.x + rect.width / 2;
1097
+ const cy = rect.y + rect.height / 2;
1098
+ if (Math.abs(cx - targetInfo.x) < 5 && Math.abs(cy - targetInfo.y) < 5) {
1099
+ return el;
1100
+ }
1101
+ }
1102
+ }
1103
+ // Fallback: find closest visible file input
1104
+ for (const el of elements) {
1105
+ const rect = el.getBoundingClientRect();
1106
+ if (rect.width > 0 && rect.height > 0) return el;
1107
+ }
1108
+ return null;
1109
+ }, { tag: target.tag, type: target.type, x: target.x, y: target.y });
1110
+
1111
+ if (!fileInput || !(await fileInput.isVisible().catch(() => false))) {
1112
+ // Use filechooser event as fallback
1113
+ const [chooser] = await Promise.all([
1114
+ this.page.waitForEvent("filechooser", { timeout: 5000 }),
1115
+ this.page.mouse.click(target.x, target.y),
1116
+ ]);
1117
+ await chooser.setFiles(allPaths);
1118
+ } else {
1119
+ await fileInput.setInputFiles(allPaths);
1120
+ }
1121
+
1122
+ await this.smartWait();
1123
+ const screenshotPath = await this.captureStepScreenshot(`upload-${id}`);
1124
+ this.artifacts.appendEvent("upload", { id, target, fileCount: allPaths.length, screenshotPath });
1125
+ return { id, target, fileCount: allPaths.length, screenshotPath };
1126
+ }
1127
+
1128
+ async drag(fromId, toId) {
1129
+ const from = await this.resolveTarget(fromId);
1130
+ const to = await this.resolveTarget(toId);
1131
+
1132
+ // Move to source, press down
1133
+ await this.page.mouse.move(from.x, from.y);
1134
+ await this.page.waitForTimeout(100);
1135
+ await this.page.mouse.down();
1136
+
1137
+ // Stepwise movement toward target for realistic drag
1138
+ const steps = 6;
1139
+ for (let i = 1; i <= steps; i++) {
1140
+ const progress = i / steps;
1141
+ const x = from.x + (to.x - from.x) * progress;
1142
+ const y = from.y + (to.y - from.y) * progress;
1143
+ await this.page.mouse.move(x, y);
1144
+ await this.page.waitForTimeout(30);
1145
+ }
1146
+
1147
+ // Release on target
1148
+ await this.page.mouse.move(to.x, to.y);
1149
+ await this.page.mouse.up();
1150
+
1151
+ await this.smartWait();
1152
+ const screenshotPath = await this.captureStepScreenshot(`drag-${fromId}-to-${toId}`);
1153
+ this.artifacts.appendEvent("drag", { fromId, toId, from, to, screenshotPath });
1154
+ return { fromId, toId, from, to, screenshotPath };
1155
+ }
1156
+
1157
+ async dragFile({ paths = [], files = [], toId } = {}) {
1158
+ const base64Paths = files.length > 0 ? writeBase64Files(files) : [];
1159
+ const allPaths = [...paths, ...base64Paths];
1160
+
1161
+ if (allPaths.length === 0) {
1162
+ throw new Error("No files provided. Supply 'paths' (local file paths) or 'files' (base64-encoded file objects).");
1163
+ }
1164
+
1165
+ for (const filePath of allPaths) {
1166
+ if (!fs.existsSync(filePath)) {
1167
+ throw new Error(`File not found: ${filePath}`);
1168
+ }
1169
+ }
1170
+
1171
+ const target = await this.resolveTarget(toId);
1172
+
1173
+ // Dispatch a synthetic drop event with files on the target element
1174
+ const dropAccepted = await this.page.evaluate(
1175
+ async ({ x, y, filePaths }) => {
1176
+ const elementAtPoint = document.elementFromPoint(x, y);
1177
+ if (!elementAtPoint) return false;
1178
+
1179
+ // Create a DataTransfer with the file references
1180
+ // Note: browser cannot read local files directly, so we use a different approach
1181
+ // We'll try the filechooser approach if the drop zone has a hidden file input
1182
+ const dropZone = elementAtPoint.closest("[class*='drop'], [class*='upload'], [data-testid*='drop'], [role='presentation']") || elementAtPoint;
1183
+
1184
+ // Try to find a hidden file input within the drop zone
1185
+ const fileInput = dropZone.querySelector('input[type="file"]') ||
1186
+ dropZone.closest("form")?.querySelector('input[type="file"]');
1187
+
1188
+ if (fileInput) {
1189
+ return { hasFileInput: true };
1190
+ }
1191
+
1192
+ // Dispatch drag events for visual feedback
1193
+ const dataTransfer = new DataTransfer();
1194
+ for (const eventType of ["dragenter", "dragover", "drop"]) {
1195
+ const event = new DragEvent(eventType, {
1196
+ bubbles: true,
1197
+ cancelable: true,
1198
+ dataTransfer,
1199
+ clientX: x,
1200
+ clientY: y,
1201
+ });
1202
+ elementAtPoint.dispatchEvent(event);
1203
+ }
1204
+
1205
+ return { hasFileInput: false };
1206
+ },
1207
+ { x: target.x, y: target.y, filePaths: allPaths }
1208
+ );
1209
+
1210
+ // If a file input was found, set files on it
1211
+ if (dropAccepted?.hasFileInput) {
1212
+ const fileInput = await this.page.evaluateHandle(({ x, y }) => {
1213
+ const elementAtPoint = document.elementFromPoint(x, y);
1214
+ if (!elementAtPoint) return null;
1215
+ const dropZone = elementAtPoint.closest("[class*='drop'], [class*='upload'], [data-testid*='drop'], [role='presentation']") || elementAtPoint;
1216
+ return dropZone.querySelector('input[type="file"]') ||
1217
+ dropZone.closest("form")?.querySelector('input[type="file"]');
1218
+ }, { x: target.x, y: target.y });
1219
+
1220
+ if (fileInput) {
1221
+ await fileInput.setInputFiles(allPaths);
1222
+ }
1223
+ }
1224
+
1225
+ await this.smartWait();
1226
+ const screenshotPath = await this.captureStepScreenshot(`dragfile-to-${toId}`);
1227
+ this.artifacts.appendEvent("dragFile", { toId, target, fileCount: allPaths.length, screenshotPath });
1228
+ return { toId, target, fileCount: allPaths.length, screenshotPath };
1229
+ }
1230
+
1231
+ async getPageMetadata(textLimit = 1500) {
1232
+ await this.ensureStarted();
1233
+ const title = await this.page.title().catch(() => "");
1234
+ const textPreview = await this.page.evaluate((limit) => {
1235
+ const bodyText = document.body?.innerText || "";
1236
+ return bodyText.replace(/\s+/g, " ").trim().slice(0, limit);
1237
+ }, textLimit).catch(() => "");
1238
+
1239
+ return {
1240
+ url: this.page.url(),
1241
+ title,
1242
+ textPreview,
1243
+ };
1244
+ }
1245
+
1246
+ async getCdpHealth(options = {}) {
1247
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, options.timeoutMs) : 3000;
1248
+ const endpoint = String(options.endpoint || this.cdpEndpoint || "").trim();
1249
+ if (!endpoint) {
1250
+ return {
1251
+ ok: false,
1252
+ endpoint: null,
1253
+ versionUrl: null,
1254
+ error: "CDP endpoint is empty.",
1255
+ };
1256
+ }
1257
+
1258
+ let versionUrl = endpoint;
1259
+ if (!versionUrl.includes("/json/version")) {
1260
+ try {
1261
+ versionUrl = new URL("/json/version", endpoint).toString();
1262
+ } catch (error) {
1263
+ return {
1264
+ ok: false,
1265
+ endpoint,
1266
+ versionUrl: null,
1267
+ error: `Invalid CDP endpoint: ${error.message}`,
1268
+ };
1269
+ }
1270
+ }
1271
+
1272
+ const controller = new AbortController();
1273
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1274
+
1275
+ try {
1276
+ const response = await fetch(versionUrl, {
1277
+ method: "GET",
1278
+ signal: controller.signal,
1279
+ headers: {
1280
+ Accept: "application/json",
1281
+ },
1282
+ });
1283
+
1284
+ if (!response.ok) {
1285
+ return {
1286
+ ok: false,
1287
+ endpoint,
1288
+ versionUrl,
1289
+ status: response.status,
1290
+ error: `Unexpected status ${response.status}.`,
1291
+ };
1292
+ }
1293
+
1294
+ const data = await response.json();
1295
+ return {
1296
+ ok: true,
1297
+ endpoint,
1298
+ versionUrl,
1299
+ browser: data?.Browser || null,
1300
+ protocolVersion: data?.["Protocol-Version"] || null,
1301
+ webSocketDebuggerUrl: data?.webSocketDebuggerUrl || null,
1302
+ userAgent: data?.["User-Agent"] || null,
1303
+ };
1304
+ } catch (error) {
1305
+ return {
1306
+ ok: false,
1307
+ endpoint,
1308
+ versionUrl,
1309
+ error: error.name === "AbortError"
1310
+ ? `Timed out after ${timeoutMs}ms.`
1311
+ : error.message,
1312
+ };
1313
+ } finally {
1314
+ clearTimeout(timeoutId);
1315
+ }
1316
+ }
1317
+
1318
+ async getCdpDiagnostics(options = {}) {
1319
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, options.timeoutMs) : 3000;
1320
+ const endpoint = String(options.endpoint || this.cdpEndpoint || "").trim();
1321
+ const health = await this.getCdpHealth({ endpoint, timeoutMs });
1322
+ const hints = [];
1323
+ const warnings = [];
1324
+
1325
+ let targets = {
1326
+ endpoint: endpoint || null,
1327
+ listUrl: null,
1328
+ count: null,
1329
+ pageTargets: null,
1330
+ serviceWorkerTargets: null,
1331
+ browserTargets: null,
1332
+ sample: [],
1333
+ error: null,
1334
+ };
1335
+
1336
+ if (!health.ok) {
1337
+ const healthError = String(health.error || "");
1338
+ if (health.status === 404) {
1339
+ hints.push("CDP endpoint is reachable but /json/version returned 404. Enable remote debugging from chrome://inspect/#remote-debugging and verify the endpoint.");
1340
+ } else if (healthError.includes("Timed out")) {
1341
+ hints.push("CDP endpoint timed out. Ensure Chrome is running and remote debugging is enabled.");
1342
+ } else if (healthError.toLowerCase().includes("fetch failed") || healthError.toLowerCase().includes("ecconnrefused")) {
1343
+ hints.push("CDP endpoint refused the connection. Start Chrome remote debugging and check the host/port.");
1344
+ } else if (healthError.toLowerCase().includes("invalid cdp endpoint")) {
1345
+ hints.push("CDP endpoint format is invalid. Use a URL like http://127.0.0.1:9222.");
1346
+ } else {
1347
+ hints.push("CDP health check failed. Confirm chrome://inspect/#remote-debugging is enabled for the browser instance.");
1348
+ }
1349
+
1350
+ hints.push("If you continue with browser_source=auto, the runtime will fall back to managed mode.");
1351
+ return {
1352
+ ok: false,
1353
+ endpoint: endpoint || null,
1354
+ health,
1355
+ targets,
1356
+ hints,
1357
+ warnings,
1358
+ };
1359
+ }
1360
+
1361
+ try {
1362
+ const listUrl = health.versionUrl
1363
+ ? new URL("/json/list", health.versionUrl).toString()
1364
+ : new URL("/json/list", endpoint).toString();
1365
+ targets.listUrl = listUrl;
1366
+ const response = await fetch(listUrl, {
1367
+ method: "GET",
1368
+ headers: {
1369
+ Accept: "application/json",
1370
+ },
1371
+ });
1372
+
1373
+ if (!response.ok) {
1374
+ targets.error = `Unexpected status ${response.status} from /json/list.`;
1375
+ warnings.push(targets.error);
1376
+ } else {
1377
+ const list = await response.json();
1378
+ const typed = Array.isArray(list) ? list : [];
1379
+ const byType = typed.reduce((acc, item) => {
1380
+ const type = item?.type || "unknown";
1381
+ acc[type] = (acc[type] || 0) + 1;
1382
+ return acc;
1383
+ }, {});
1384
+ targets.count = typed.length;
1385
+ targets.pageTargets = byType.page || 0;
1386
+ targets.serviceWorkerTargets = byType.service_worker || 0;
1387
+ targets.browserTargets = byType.browser || 0;
1388
+ targets.sample = typed.slice(0, 3).map((item) => ({
1389
+ type: item?.type || null,
1390
+ title: item?.title || null,
1391
+ url: item?.url || null,
1392
+ }));
1393
+
1394
+ if (targets.pageTargets === 0) {
1395
+ warnings.push("No page targets are open in the attached profile. Open a regular Chrome tab before starting an attached run.");
1396
+ }
1397
+ }
1398
+ } catch (error) {
1399
+ targets.error = error.message;
1400
+ warnings.push(`Could not inspect /json/list: ${error.message}`);
1401
+ }
1402
+
1403
+ if (!health.webSocketDebuggerUrl) {
1404
+ warnings.push("No webSocketDebuggerUrl was reported by /json/version. Attach may be unstable.");
1405
+ }
1406
+
1407
+ if (!warnings.length) {
1408
+ hints.push("CDP endpoint looks healthy. Attached mode should be available.");
1409
+ } else {
1410
+ hints.push("CDP endpoint is reachable, but diagnostics found issues that may affect attached runs.");
1411
+ hints.push("You can still run with browser_source=auto to attach when possible and fall back to managed mode.");
1412
+ }
1413
+
1414
+ return {
1415
+ ok: warnings.length === 0,
1416
+ endpoint: endpoint || null,
1417
+ health,
1418
+ targets,
1419
+ hints,
1420
+ warnings,
1421
+ };
1422
+ }
1423
+
1424
+ async auditTextLayout(options = {}) {
1425
+ await this.ensureStarted();
1426
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 80;
1427
+ const selectors = typeof options.selectors === "string" && options.selectors.trim()
1428
+ ? options.selectors.trim()
1429
+ : DEFAULT_TEXT_AUDIT_SELECTORS;
1430
+ const overflowThreshold = Number.isFinite(options.overflowThreshold)
1431
+ ? Math.max(0, Number(options.overflowThreshold))
1432
+ : 1;
1433
+
1434
+ const audit = await this.page.evaluate(({ limit, selectors, overflowThreshold }) => {
1435
+ function parsePx(value, fallback = 0) {
1436
+ if (typeof value !== "string") return fallback;
1437
+ const num = Number.parseFloat(value);
1438
+ return Number.isFinite(num) ? num : fallback;
1439
+ }
1440
+
1441
+ function toSelector(node) {
1442
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) {
1443
+ return "";
1444
+ }
1445
+
1446
+ if (node.id) {
1447
+ const escaped = typeof CSS !== "undefined" && typeof CSS.escape === "function"
1448
+ ? CSS.escape(node.id)
1449
+ : node.id.replace(/[^a-zA-Z0-9_-]/g, "");
1450
+ return `#${escaped}`;
1451
+ }
1452
+
1453
+ const parts = [];
1454
+ let current = node;
1455
+ while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 4) {
1456
+ const parent = current.parentElement;
1457
+ const tag = current.tagName.toLowerCase();
1458
+ if (!parent) {
1459
+ parts.unshift(tag);
1460
+ break;
1461
+ }
1462
+ const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
1463
+ if (siblings.length === 1) {
1464
+ parts.unshift(tag);
1465
+ } else {
1466
+ const index = siblings.indexOf(current) + 1;
1467
+ parts.unshift(`${tag}:nth-of-type(${index})`);
1468
+ }
1469
+ current = parent;
1470
+ }
1471
+ return parts.join(" > ");
1472
+ }
1473
+
1474
+ function toGraphemes(text) {
1475
+ if (!text) return [];
1476
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
1477
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1478
+ return Array.from(segmenter.segment(text), (entry) => entry.segment);
1479
+ }
1480
+ return Array.from(text);
1481
+ }
1482
+
1483
+ function estimateLineCount(text, maxWidth, font) {
1484
+ if (!text) return 0;
1485
+ if (!Number.isFinite(maxWidth) || maxWidth <= 0) return 1;
1486
+
1487
+ const canvas = document.createElement("canvas");
1488
+ const context = canvas.getContext("2d");
1489
+ if (!context) {
1490
+ return null;
1491
+ }
1492
+ context.font = font;
1493
+
1494
+ const paragraphs = text.split("\n");
1495
+ let lineCount = 0;
1496
+ for (const paragraph of paragraphs) {
1497
+ if (!paragraph.length) {
1498
+ lineCount += 1;
1499
+ continue;
1500
+ }
1501
+ let lineWidth = 0;
1502
+ let activeLine = 1;
1503
+ const graphemes = toGraphemes(paragraph);
1504
+ for (const grapheme of graphemes) {
1505
+ const width = context.measureText(grapheme).width;
1506
+ if (lineWidth + width > maxWidth && lineWidth > 0) {
1507
+ activeLine += 1;
1508
+ lineWidth = width;
1509
+ } else {
1510
+ lineWidth += width;
1511
+ }
1512
+ }
1513
+ lineCount += activeLine;
1514
+ }
1515
+ return lineCount;
1516
+ }
1517
+
1518
+ const result = [];
1519
+ const elements = Array.from(document.querySelectorAll(selectors));
1520
+
1521
+ for (const node of elements) {
1522
+ if (result.length >= limit) break;
1523
+ if (!(node instanceof HTMLElement)) continue;
1524
+
1525
+ const style = window.getComputedStyle(node);
1526
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
1527
+ continue;
1528
+ }
1529
+
1530
+ const text = (node.innerText || node.textContent || "").trim();
1531
+ if (!text) continue;
1532
+
1533
+ const lineHeight = parsePx(style.lineHeight, parsePx(style.fontSize, 16) * 1.2);
1534
+ const font = style.font && style.font !== "normal normal normal normal 16px / normal sans-serif"
1535
+ ? style.font
1536
+ : `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
1537
+ const paddingLeft = parsePx(style.paddingLeft, 0);
1538
+ const paddingRight = parsePx(style.paddingRight, 0);
1539
+ const maxTextWidth = Math.max(0, node.clientWidth - paddingLeft - paddingRight);
1540
+ const normalizedText = style.whiteSpace.startsWith("pre")
1541
+ ? text
1542
+ : text.replace(/\s+/g, " ");
1543
+ const estimatedLineCount = estimateLineCount(normalizedText, maxTextWidth, font);
1544
+ const expectedHeight = Number.isFinite(estimatedLineCount) ? estimatedLineCount * lineHeight : null;
1545
+
1546
+ const hasActualOverflow = (node.scrollWidth - node.clientWidth > overflowThreshold)
1547
+ || (node.scrollHeight - node.clientHeight > overflowThreshold);
1548
+ const hasEstimatedOverflow = Number.isFinite(expectedHeight)
1549
+ ? expectedHeight - node.clientHeight > overflowThreshold
1550
+ : false;
1551
+
1552
+ if (!hasActualOverflow && !hasEstimatedOverflow) {
1553
+ continue;
1554
+ }
1555
+
1556
+ result.push({
1557
+ selector: toSelector(node),
1558
+ tag: node.tagName.toLowerCase(),
1559
+ text: normalizedText.slice(0, 200),
1560
+ whiteSpace: style.whiteSpace,
1561
+ font,
1562
+ lineHeight,
1563
+ maxTextWidth,
1564
+ clientHeight: node.clientHeight,
1565
+ scrollHeight: node.scrollHeight,
1566
+ clientWidth: node.clientWidth,
1567
+ scrollWidth: node.scrollWidth,
1568
+ estimatedLineCount,
1569
+ expectedHeight,
1570
+ hasActualOverflow,
1571
+ hasEstimatedOverflow,
1572
+ });
1573
+ }
1574
+
1575
+ return {
1576
+ summary: {
1577
+ checkedElements: elements.length,
1578
+ flaggedElements: result.length,
1579
+ hasIssues: result.length > 0,
1580
+ },
1581
+ flaggedElements: result,
1582
+ };
1583
+ }, { limit, selectors, overflowThreshold });
1584
+
1585
+ this.artifacts.appendEvent("text_layout_audit", {
1586
+ ...audit.summary,
1587
+ limit,
1588
+ selectors,
1589
+ });
1590
+
1591
+ return {
1592
+ ...audit,
1593
+ page: await this.getPageMetadata(400),
1594
+ options: {
1595
+ limit,
1596
+ selectors,
1597
+ overflowThreshold,
1598
+ },
1599
+ };
1600
+ }
1601
+
1602
+ recordEvent(type, payload = {}) {
1603
+ if (!this.artifacts.sessionDir) return;
1604
+ this.artifacts.appendEvent(type, payload);
1605
+ }
1606
+
1607
+ writeArtifactJson(filename, data) {
1608
+ return this.artifacts.writeJson(filename, data);
1609
+ }
1610
+
1611
+ writeArtifactText(filename, text) {
1612
+ return this.artifacts.writeText(filename, text);
1613
+ }
1614
+
1615
+ getDebugState(limit = 20) {
1616
+ const artifacts = this.artifacts.sessionDir ? this.artifacts.getSummary() : this.lastSessionSummary;
1617
+ const capabilities = this.sessionSource === "attached"
1618
+ ? {
1619
+ sessionReuse: true,
1620
+ visibleBrowser: true,
1621
+ videos: this.attachedVideoCapability,
1622
+ traces: this.traceActive,
1623
+ modeSwitching: false,
1624
+ }
1625
+ : {
1626
+ sessionReuse: true,
1627
+ visibleBrowser: this.browserMode === "headful",
1628
+ videos: true,
1629
+ traces: true,
1630
+ modeSwitching: true,
1631
+ };
1632
+
1633
+ return {
1634
+ active: Boolean(this.browser),
1635
+ requestedSource: this.requestedSource,
1636
+ sessionSource: this.sessionSource,
1637
+ autoFallbackReason: this.autoFallbackReason,
1638
+ cdpEndpoint: this.cdpEndpoint,
1639
+ browserMode: this.browserMode,
1640
+ manualControlActive: this.manualControlActive,
1641
+ capabilities,
1642
+ recentConsole: this.consoleMessages.slice(-limit),
1643
+ recentNetwork: this.networkEvents.slice(-limit),
1644
+ recentErrors: this.pageErrors.slice(-limit),
1645
+ counts: {
1646
+ console: this.consoleMessages.length,
1647
+ network: this.networkEvents.length,
1648
+ errors: this.pageErrors.length,
1649
+ observedElements: this.interactableElements.length,
1650
+ },
1651
+ artifacts,
1652
+ };
1653
+ }
1654
+
1655
+ flushLogsToArtifacts() {
1656
+ if (!this.artifacts.sessionDir) return;
1657
+
1658
+ try {
1659
+ if (this.consoleMessages.length > 0) {
1660
+ const consolePath = this.artifacts.getConsoleLogPath();
1661
+ fs.writeFileSync(consolePath, JSON.stringify(this.consoleMessages, null, 2), "utf8");
1662
+ }
1663
+ if (this.networkEvents.length > 0) {
1664
+ const networkPath = this.artifacts.getNetworkLogPath();
1665
+ fs.writeFileSync(networkPath, JSON.stringify(this.networkEvents, null, 2), "utf8");
1666
+ }
1667
+ if (this.pageErrors.length > 0) {
1668
+ const errorsPath = this.artifacts.getErrorLogPath();
1669
+ fs.writeFileSync(errorsPath, JSON.stringify(this.pageErrors, null, 2), "utf8");
1670
+ }
1671
+ } catch (error) {
1672
+ console.error(`Failed to flush logs to artifacts: ${error.message}`);
1673
+ }
1674
+ }
1675
+
1676
+ async stop() {
1677
+ if (!this.browser) {
1678
+ return {
1679
+ alreadyStopped: true,
1680
+ artifacts: this.lastSessionSummary,
1681
+ };
1682
+ }
1683
+
1684
+ this.artifacts.appendEvent("session_stopping", this.getDebugState(10));
1685
+ await this.stopAttachedScreencast({
1686
+ reason: "session-stop",
1687
+ persist: true,
1688
+ });
1689
+ await saveBrowserState(this.context, this.storageStatePath);
1690
+
1691
+ await this.finalizeTraceSegment("session-stop");
1692
+ await this.browser.close();
1693
+
1694
+ // Persist in-memory logs to artifacts before resetting
1695
+ this.flushLogsToArtifacts();
1696
+
1697
+ this.lastSessionSummary = this.artifacts.getSummary();
1698
+ const result = {
1699
+ alreadyStopped: false,
1700
+ artifacts: this.lastSessionSummary,
1701
+ };
1702
+
1703
+ this.resetRuntimeState();
1704
+ return result;
1705
+ }
1706
+ }