@ulpi/browse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Browser lifecycle manager
3
+ *
4
+ * Chromium crash handling:
5
+ * browser.on('disconnected') → log error → process.exit(1)
6
+ * CLI detects dead server → auto-restarts on next command
7
+ * We do NOT try to self-heal — don't hide failure.
8
+ */
9
+
10
+ import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Request as PlaywrightRequest } from 'playwright';
11
+ import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
12
+
13
+ /** Shorthand aliases for common devices → Playwright device names */
14
+ const DEVICE_ALIASES: Record<string, string> = {
15
+ 'iphone': 'iPhone 15',
16
+ 'iphone-12': 'iPhone 12',
17
+ 'iphone-13': 'iPhone 13',
18
+ 'iphone-14': 'iPhone 14',
19
+ 'iphone-15': 'iPhone 15',
20
+ 'iphone-14-pro': 'iPhone 14 Pro Max',
21
+ 'iphone-15-pro': 'iPhone 15 Pro Max',
22
+ 'iphone-16': 'iPhone 16',
23
+ 'iphone-16-pro': 'iPhone 16 Pro',
24
+ 'iphone-16-pro-max': 'iPhone 16 Pro Max',
25
+ 'iphone-17': 'iPhone 17',
26
+ 'iphone-17-pro': 'iPhone 17 Pro',
27
+ 'iphone-17-pro-max': 'iPhone 17 Pro Max',
28
+ 'iphone-se': 'iPhone SE',
29
+ 'pixel': 'Pixel 7',
30
+ 'pixel-7': 'Pixel 7',
31
+ 'pixel-5': 'Pixel 5',
32
+ 'samsung': 'Galaxy S9+',
33
+ 'galaxy': 'Galaxy S9+',
34
+ 'ipad': 'iPad (gen 7)',
35
+ 'ipad-pro': 'iPad Pro 11',
36
+ 'ipad-mini': 'iPad Mini',
37
+ };
38
+
39
+ /** Custom device descriptors for devices not yet in Playwright's built-in list */
40
+ const CUSTOM_DEVICES: Record<string, DeviceDescriptor> = {
41
+ 'iPhone 16': {
42
+ viewport: { width: 393, height: 852 },
43
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
44
+ deviceScaleFactor: 3,
45
+ isMobile: true,
46
+ hasTouch: true,
47
+ },
48
+ 'iPhone 16 Pro': {
49
+ viewport: { width: 402, height: 874 },
50
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
51
+ deviceScaleFactor: 3,
52
+ isMobile: true,
53
+ hasTouch: true,
54
+ },
55
+ 'iPhone 16 Pro Max': {
56
+ viewport: { width: 440, height: 956 },
57
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
58
+ deviceScaleFactor: 3,
59
+ isMobile: true,
60
+ hasTouch: true,
61
+ },
62
+ 'iPhone 17': {
63
+ viewport: { width: 393, height: 852 },
64
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1',
65
+ deviceScaleFactor: 3,
66
+ isMobile: true,
67
+ hasTouch: true,
68
+ },
69
+ 'iPhone 17 Pro': {
70
+ viewport: { width: 402, height: 874 },
71
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1',
72
+ deviceScaleFactor: 3,
73
+ isMobile: true,
74
+ hasTouch: true,
75
+ },
76
+ 'iPhone 17 Pro Max': {
77
+ viewport: { width: 440, height: 956 },
78
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1',
79
+ deviceScaleFactor: 3,
80
+ isMobile: true,
81
+ hasTouch: true,
82
+ },
83
+ };
84
+
85
+ export interface DeviceDescriptor {
86
+ viewport: { width: number; height: number };
87
+ userAgent: string;
88
+ deviceScaleFactor: number;
89
+ isMobile: boolean;
90
+ hasTouch: boolean;
91
+ }
92
+
93
+ /** Resolve a device name (alias or Playwright name or custom) to a descriptor, or null */
94
+ export function resolveDevice(name: string): DeviceDescriptor | null {
95
+ // Check aliases first (case-insensitive)
96
+ const alias = DEVICE_ALIASES[name.toLowerCase()];
97
+ const aliasTarget = alias || name;
98
+
99
+ // Check custom devices
100
+ if (CUSTOM_DEVICES[aliasTarget]) {
101
+ return CUSTOM_DEVICES[aliasTarget];
102
+ }
103
+ // Direct Playwright device name lookup
104
+ if (playwrightDevices[aliasTarget]) {
105
+ return playwrightDevices[aliasTarget] as DeviceDescriptor;
106
+ }
107
+ // Fuzzy: try case-insensitive match across both lists
108
+ const lower = name.toLowerCase();
109
+ for (const [key, desc] of Object.entries(CUSTOM_DEVICES)) {
110
+ if (key.toLowerCase() === lower) return desc;
111
+ }
112
+ for (const [key, desc] of Object.entries(playwrightDevices)) {
113
+ if (key.toLowerCase() === lower) {
114
+ return desc as DeviceDescriptor;
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** List all available device names */
121
+ export function listDevices(): string[] {
122
+ const all = new Set([
123
+ ...Object.keys(CUSTOM_DEVICES),
124
+ ...Object.keys(playwrightDevices),
125
+ ]);
126
+ return [...all].sort();
127
+ }
128
+
129
+ export class BrowserManager {
130
+ private browser: Browser | null = null;
131
+ private context: BrowserContext | null = null;
132
+ private pages: Map<number, Page> = new Map();
133
+ private activeTabId: number = 0;
134
+ private nextTabId: number = 1;
135
+ private extraHeaders: Record<string, string> = {};
136
+ private customUserAgent: string | null = null;
137
+ private currentDevice: DeviceDescriptor | null = null;
138
+
139
+ // ─── Per-session buffers ──────────────────────────────────
140
+ private buffers: SessionBuffers;
141
+
142
+ // ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
143
+ // Refs are scoped per tab — switching tabs invalidates refs from the previous tab.
144
+ private refMap: Map<string, Locator> = new Map();
145
+ private refTabId: number = 0; // Which tab the current refs belong to
146
+
147
+ // ─── Last Snapshot (for snapshot-diff) ─────────────────────
148
+ // Per-tab so snapshot-diff compares the correct baseline after tab switches
149
+ private tabSnapshots: Map<number, { text: string; opts: string[] }> = new Map();
150
+
151
+ // ─── Dialog Handling ──────────────────────────────────────
152
+ private lastDialog: { type: string; message: string; defaultValue?: string } | null = null;
153
+ private autoDialogAction: 'accept' | 'dismiss' = 'dismiss';
154
+ private dialogPromptValue: string | undefined;
155
+
156
+ // ─── Network Correlation ────────────────────────────────────
157
+ private requestEntryMap = new WeakMap<PlaywrightRequest, NetworkEntry>();
158
+
159
+ // Whether this instance owns (and should close) the Browser process
160
+ private ownsBrowser = false;
161
+
162
+ constructor(buffers?: SessionBuffers) {
163
+ this.buffers = buffers || new SessionBuffers();
164
+ }
165
+
166
+ getBuffers(): SessionBuffers {
167
+ return this.buffers;
168
+ }
169
+
170
+ /**
171
+ * Launch a new Chromium browser (single-session / multi-process mode).
172
+ * This instance owns the browser and will close it on close().
173
+ */
174
+ async launch(onCrash?: () => void) {
175
+ this.browser = await chromium.launch({ headless: true });
176
+ this.ownsBrowser = true;
177
+
178
+ // Chromium crash → flush what we can, then exit
179
+ this.browser.on('disconnected', () => {
180
+ console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
181
+ if (onCrash) onCrash();
182
+ process.exit(1);
183
+ });
184
+
185
+ this.context = await this.browser.newContext({
186
+ viewport: { width: 1920, height: 1080 },
187
+ ...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
188
+ });
189
+
190
+ // Create first tab
191
+ await this.newTab();
192
+ }
193
+
194
+ /**
195
+ * Attach to an existing Browser instance (session multiplexing mode).
196
+ * Creates a new BrowserContext on the shared browser.
197
+ * This instance does NOT own the browser — close() only closes the context.
198
+ */
199
+ async launchWithBrowser(browser: Browser) {
200
+ this.browser = browser;
201
+ this.ownsBrowser = false;
202
+
203
+ this.context = await browser.newContext({
204
+ viewport: { width: 1920, height: 1080 },
205
+ ...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
206
+ });
207
+
208
+ // Create first tab
209
+ await this.newTab();
210
+ }
211
+
212
+ async close() {
213
+ // Close all pages first
214
+ for (const [, page] of this.pages) {
215
+ await page.close().catch(() => {});
216
+ }
217
+ this.pages.clear();
218
+ this.tabSnapshots.clear();
219
+ this.refMap.clear();
220
+
221
+ if (this.context) {
222
+ await this.context.close().catch(() => {});
223
+ this.context = null;
224
+ }
225
+
226
+ if (this.ownsBrowser && this.browser) {
227
+ // Remove disconnect handler to avoid exit during intentional close
228
+ this.browser.removeAllListeners('disconnected');
229
+ await this.browser.close();
230
+ this.browser = null;
231
+ }
232
+ }
233
+
234
+ isHealthy(): boolean {
235
+ return this.browser !== null && this.browser.isConnected();
236
+ }
237
+
238
+ // ─── Tab Management ────────────────────────────────────────
239
+ async newTab(url?: string): Promise<number> {
240
+ if (!this.context) throw new Error('Browser not launched');
241
+
242
+ const page = await this.context.newPage();
243
+
244
+ // Wire up console/network capture before navigation so we capture everything
245
+ this.wirePageEvents(page);
246
+
247
+ // Navigate before committing the tab — if goto fails, close page and rethrow
248
+ if (url) {
249
+ try {
250
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
251
+ } catch (err) {
252
+ await page.close().catch(() => {});
253
+ throw err;
254
+ }
255
+ }
256
+
257
+ // Only commit tab state after successful creation + navigation
258
+ const id = this.nextTabId++;
259
+ this.pages.set(id, page);
260
+ this.activeTabId = id;
261
+
262
+ return id;
263
+ }
264
+
265
+ async closeTab(id?: number): Promise<void> {
266
+ const tabId = id ?? this.activeTabId;
267
+ const page = this.pages.get(tabId);
268
+ if (!page) throw new Error(`Tab ${tabId} not found`);
269
+
270
+ await page.close();
271
+ this.pages.delete(tabId);
272
+ this.tabSnapshots.delete(tabId);
273
+
274
+ // Switch to another tab if we closed the active one
275
+ if (tabId === this.activeTabId) {
276
+ const remaining = [...this.pages.keys()];
277
+ if (remaining.length > 0) {
278
+ this.activeTabId = remaining[remaining.length - 1];
279
+ } else {
280
+ // No tabs left — create a new blank one
281
+ await this.newTab();
282
+ }
283
+ }
284
+ }
285
+
286
+ switchTab(id: number): void {
287
+ if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
288
+ this.activeTabId = id;
289
+ }
290
+
291
+ getActiveTabId(): number {
292
+ return this.activeTabId;
293
+ }
294
+
295
+ hasTab(id: number): boolean {
296
+ return this.pages.has(id);
297
+ }
298
+
299
+ getTabCount(): number {
300
+ return this.pages.size;
301
+ }
302
+
303
+ getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> {
304
+ const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
305
+ for (const [id, page] of this.pages) {
306
+ tabs.push({
307
+ id,
308
+ url: page.url(),
309
+ title: '', // title requires await, populated by caller
310
+ active: id === this.activeTabId,
311
+ });
312
+ }
313
+ return tabs;
314
+ }
315
+
316
+ async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
317
+ const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
318
+ for (const [id, page] of this.pages) {
319
+ tabs.push({
320
+ id,
321
+ url: page.url(),
322
+ title: await page.title().catch(() => ''),
323
+ active: id === this.activeTabId,
324
+ });
325
+ }
326
+ return tabs;
327
+ }
328
+
329
+ // ─── Page Access ───────────────────────────────────────────
330
+ getPage(): Page {
331
+ const page = this.pages.get(this.activeTabId);
332
+ if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
333
+ return page;
334
+ }
335
+
336
+ getCurrentUrl(): string {
337
+ try {
338
+ return this.getPage().url();
339
+ } catch {
340
+ return 'about:blank';
341
+ }
342
+ }
343
+
344
+ // ─── Ref Map ──────────────────────────────────────────────
345
+ setRefMap(refs: Map<string, Locator>) {
346
+ this.refMap = refs;
347
+ this.refTabId = this.activeTabId;
348
+ }
349
+
350
+ clearRefs() {
351
+ this.refMap.clear();
352
+ }
353
+
354
+ /**
355
+ * Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
356
+ * Returns { locator } for refs or { selector } for CSS selectors.
357
+ */
358
+ resolveRef(selector: string): { locator: Locator } | { selector: string } {
359
+ if (selector.startsWith('@e')) {
360
+ // Refs are scoped to the tab that created them — reject cross-tab usage
361
+ if (this.refTabId !== this.activeTabId) {
362
+ throw new Error(
363
+ `Refs were created on tab ${this.refTabId}, but active tab is ${this.activeTabId}. ` +
364
+ `Run 'snapshot' on the current tab to get fresh refs.`
365
+ );
366
+ }
367
+ const ref = selector.slice(1); // "e3"
368
+ const locator = this.refMap.get(ref);
369
+ if (!locator) {
370
+ throw new Error(
371
+ `Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
372
+ );
373
+ }
374
+ return { locator };
375
+ }
376
+ return { selector };
377
+ }
378
+
379
+ getRefCount(): number {
380
+ return this.refMap.size;
381
+ }
382
+
383
+ getRefMap(): Map<string, Locator> {
384
+ return this.refMap;
385
+ }
386
+
387
+ /**
388
+ * Check if refs are valid for the currently active tab.
389
+ * Used by screenshot --annotate to avoid cross-tab ref leaks.
390
+ */
391
+ areRefsValidForActiveTab(): boolean {
392
+ return this.refMap.size > 0 && this.refTabId === this.activeTabId;
393
+ }
394
+
395
+ setLastSnapshot(text: string, opts?: string[]) {
396
+ this.tabSnapshots.set(this.activeTabId, { text, opts: opts || [] });
397
+ }
398
+
399
+ getLastSnapshot(): string | null {
400
+ return this.tabSnapshots.get(this.activeTabId)?.text ?? null;
401
+ }
402
+
403
+ getLastSnapshotOpts(): string[] {
404
+ return this.tabSnapshots.get(this.activeTabId)?.opts ?? [];
405
+ }
406
+
407
+ getLastDialog(): { type: string; message: string; defaultValue?: string } | null {
408
+ return this.lastDialog;
409
+ }
410
+
411
+ setAutoDialogAction(action: 'accept' | 'dismiss', promptValue?: string) {
412
+ this.autoDialogAction = action;
413
+ this.dialogPromptValue = promptValue;
414
+ }
415
+
416
+ getAutoDialogAction(): 'accept' | 'dismiss' {
417
+ return this.autoDialogAction;
418
+ }
419
+
420
+ // ─── Viewport ──────────────────────────────────────────────
421
+ async setViewport(width: number, height: number) {
422
+ await this.getPage().setViewportSize({ width, height });
423
+ }
424
+
425
+ // ─── Extra Headers ─────────────────────────────────────────
426
+ async setExtraHeader(name: string, value: string) {
427
+ this.extraHeaders[name] = value;
428
+ if (this.context) {
429
+ await this.context.setExtraHTTPHeaders(this.extraHeaders);
430
+ }
431
+ }
432
+
433
+ // ─── User Agent ────────────────────────────────────────────
434
+ setUserAgent(ua: string) {
435
+ this.customUserAgent = ua || null;
436
+ }
437
+
438
+ getUserAgent(): string | null {
439
+ return this.customUserAgent;
440
+ }
441
+
442
+ /**
443
+ * Recreate the browser context with new options.
444
+ * Playwright requires a new context to change UA/device — existing pages are closed.
445
+ *
446
+ * Preserves: cookies, all tab URLs (not just active), active tab selection.
447
+ * Cannot preserve: localStorage/sessionStorage (bound to old context).
448
+ */
449
+ private async recreateContext(contextOptions: Record<string, any>): Promise<void> {
450
+ if (!this.browser) return;
451
+
452
+ // Save all tab URLs and which tab was active
453
+ const tabUrls: Array<{ id: number; url: string; active: boolean }> = [];
454
+ for (const [id, page] of this.pages) {
455
+ tabUrls.push({
456
+ id,
457
+ url: page.url(),
458
+ active: id === this.activeTabId,
459
+ });
460
+ }
461
+
462
+ // Save cookies from old context
463
+ const savedCookies = this.context ? await this.context.cookies() : [];
464
+
465
+ // Create new context FIRST — if this fails, old session is untouched
466
+ const newContext = await this.browser.newContext(contextOptions);
467
+
468
+ // Restore cookies and headers into new context before creating tabs
469
+ try {
470
+ if (savedCookies.length > 0) {
471
+ await newContext.addCookies(savedCookies);
472
+ }
473
+ if (Object.keys(this.extraHeaders).length > 0) {
474
+ await newContext.setExtraHTTPHeaders(this.extraHeaders);
475
+ }
476
+ } catch (err) {
477
+ await newContext.close().catch(() => {});
478
+ throw err;
479
+ }
480
+
481
+ // Save all mutable state before swapping — needed for rollback
482
+ const oldContext = this.context;
483
+ const oldPages = new Map(this.pages);
484
+ const oldActiveTabId = this.activeTabId;
485
+ const oldNextTabId = this.nextTabId;
486
+ const oldTabSnapshots = new Map(this.tabSnapshots);
487
+
488
+ // Swap to new context
489
+ this.context = newContext;
490
+ this.pages.clear();
491
+ this.nextTabId = 1;
492
+ this.refMap.clear();
493
+
494
+ // Recreate all tabs in new context, building old→new ID map for snapshot migration
495
+ const idMap = new Map<number, number>(); // oldTabId → newTabId
496
+ let activeRestoredId: number | null = null;
497
+ try {
498
+ for (const tab of tabUrls) {
499
+ const url = tab.url !== 'about:blank' ? tab.url : undefined;
500
+ const newId = await this.newTab(url);
501
+ idMap.set(tab.id, newId);
502
+ if (tab.active) {
503
+ activeRestoredId = newId;
504
+ }
505
+ }
506
+
507
+ if (tabUrls.length === 0) {
508
+ await this.newTab();
509
+ } else if (activeRestoredId !== null) {
510
+ this.activeTabId = activeRestoredId;
511
+ }
512
+ } catch (err) {
513
+ // Full rollback — restore all mutable state including snapshots
514
+ for (const [, page] of this.pages) {
515
+ await page.close().catch(() => {});
516
+ }
517
+ await newContext.close().catch(() => {});
518
+ this.context = oldContext;
519
+ this.pages = oldPages;
520
+ this.activeTabId = oldActiveTabId;
521
+ this.nextTabId = oldNextTabId;
522
+ this.tabSnapshots = oldTabSnapshots;
523
+ this.refMap.clear();
524
+ throw err;
525
+ }
526
+
527
+ // Migrate tabSnapshots: remap old tab IDs to new tab IDs
528
+ this.tabSnapshots.clear();
529
+ for (const [oldId, snapshot] of oldTabSnapshots) {
530
+ const newId = idMap.get(oldId);
531
+ if (newId !== undefined) {
532
+ this.tabSnapshots.set(newId, snapshot);
533
+ }
534
+ }
535
+
536
+ // Success — close old pages and context
537
+ for (const [, page] of oldPages) {
538
+ await page.close().catch(() => {});
539
+ }
540
+ if (oldContext) {
541
+ await oldContext.close().catch(() => {});
542
+ }
543
+ }
544
+
545
+ async applyUserAgent(): Promise<void> {
546
+ if (!this.customUserAgent) return;
547
+ await this.recreateContext({
548
+ viewport: this.currentDevice?.viewport || { width: 1920, height: 1080 },
549
+ userAgent: this.customUserAgent,
550
+ ...(this.currentDevice ? {
551
+ deviceScaleFactor: this.currentDevice.deviceScaleFactor,
552
+ isMobile: this.currentDevice.isMobile,
553
+ hasTouch: this.currentDevice.hasTouch,
554
+ } : {}),
555
+ });
556
+ }
557
+
558
+ /**
559
+ * Emulate a device — recreates context with full device settings.
560
+ * Pass null to reset to desktop defaults.
561
+ */
562
+ async emulateDevice(device: DeviceDescriptor | null): Promise<void> {
563
+ // Save state before mutation so we can rollback on failure
564
+ const prevDevice = this.currentDevice;
565
+ const prevUA = this.customUserAgent;
566
+
567
+ this.currentDevice = device;
568
+ if (device) {
569
+ this.customUserAgent = device.userAgent;
570
+ try {
571
+ await this.recreateContext({
572
+ viewport: device.viewport,
573
+ userAgent: device.userAgent,
574
+ deviceScaleFactor: device.deviceScaleFactor,
575
+ isMobile: device.isMobile,
576
+ hasTouch: device.hasTouch,
577
+ });
578
+ } catch (err) {
579
+ // Rollback device/UA state on failure
580
+ this.currentDevice = prevDevice;
581
+ this.customUserAgent = prevUA;
582
+ throw err;
583
+ }
584
+ } else {
585
+ // Reset to desktop
586
+ this.customUserAgent = null;
587
+ try {
588
+ await this.recreateContext({
589
+ viewport: { width: 1920, height: 1080 },
590
+ });
591
+ } catch (err) {
592
+ this.currentDevice = prevDevice;
593
+ this.customUserAgent = prevUA;
594
+ throw err;
595
+ }
596
+ }
597
+ }
598
+
599
+ getCurrentDevice(): DeviceDescriptor | null {
600
+ return this.currentDevice;
601
+ }
602
+
603
+ /**
604
+ * Reverse-lookup: find the tab ID that owns this page.
605
+ * Returns undefined if the page isn't committed to any tab yet (during newTab).
606
+ */
607
+ private getTabIdForPage(page: Page): number | undefined {
608
+ for (const [id, p] of this.pages) {
609
+ if (p === page) return id;
610
+ }
611
+ return undefined;
612
+ }
613
+
614
+ // ─── Console/Network/Ref Wiring ────────────────────────────
615
+ private wirePageEvents(page: Page) {
616
+ // Clear ref map on navigation — but ONLY if this page belongs to the tab
617
+ // that owns the current refs. Otherwise, navigating tab B clears tab A's refs.
618
+ page.on('framenavigated', (frame) => {
619
+ if (frame === page.mainFrame()) {
620
+ const tabId = this.getTabIdForPage(page);
621
+ if (tabId !== undefined && tabId === this.refTabId) {
622
+ this.clearRefs();
623
+ }
624
+ }
625
+ });
626
+
627
+ page.on('dialog', async (dialog) => {
628
+ this.lastDialog = {
629
+ type: dialog.type(),
630
+ message: dialog.message(),
631
+ defaultValue: dialog.defaultValue() || undefined,
632
+ };
633
+ this.buffers.addConsoleEntry({
634
+ timestamp: Date.now(),
635
+ level: 'info',
636
+ text: `[dialog] ${dialog.type()}: ${dialog.message()}`,
637
+ });
638
+ if (this.autoDialogAction === 'accept') {
639
+ // Use user-supplied prompt value if set, otherwise fall back to browser default
640
+ await dialog.accept(this.dialogPromptValue ?? dialog.defaultValue());
641
+ } else {
642
+ await dialog.dismiss();
643
+ }
644
+ });
645
+
646
+ page.on('console', (msg) => {
647
+ this.buffers.addConsoleEntry({
648
+ timestamp: Date.now(),
649
+ level: msg.type(),
650
+ text: msg.text(),
651
+ });
652
+ });
653
+
654
+ page.on('request', (req) => {
655
+ const entry: NetworkEntry = {
656
+ timestamp: Date.now(),
657
+ method: req.method(),
658
+ url: req.url(),
659
+ };
660
+ this.buffers.addNetworkEntry(entry);
661
+ // Store direct reference for accurate correlation on duplicate URLs
662
+ this.requestEntryMap.set(req, entry);
663
+ });
664
+
665
+ page.on('response', (res) => {
666
+ const entry = this.requestEntryMap.get(res.request());
667
+ if (entry) {
668
+ entry.status = res.status();
669
+ entry.duration = Date.now() - entry.timestamp;
670
+ }
671
+ });
672
+
673
+ // Capture response sizes via Content-Length header (avoids reading full body into memory)
674
+ page.on('requestfinished', async (req) => {
675
+ try {
676
+ const res = await req.response();
677
+ if (res) {
678
+ const entry = this.requestEntryMap.get(req);
679
+ if (entry) {
680
+ const cl = res.headers()['content-length'];
681
+ entry.size = cl ? parseInt(cl, 10) : 0;
682
+ }
683
+ }
684
+ } catch {}
685
+ });
686
+ }
687
+ }