@ulpi/browse 0.10.0 → 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.
- package/README.md +6 -6
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +2 -3
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -196
- package/src/browser-manager.ts +0 -976
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -65
- package/src/chrome-discover.ts +0 -73
- package/src/cli.ts +0 -783
- package/src/commands/meta.ts +0 -986
- package/src/commands/read.ts +0 -375
- package/src/commands/write.ts +0 -704
- package/src/config.ts +0 -44
- package/src/constants.ts +0 -14
- package/src/cookie-import.ts +0 -410
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/encryption.ts +0 -48
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/record-export.ts +0 -98
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -526
- package/src/session-manager.ts +0 -240
- package/src/session-persist.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/browser-manager.ts
DELETED
|
@@ -1,976 +0,0 @@
|
|
|
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 Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
|
|
11
|
-
import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
|
|
12
|
-
import type { HarRecording } from './har';
|
|
13
|
-
import type { DomainFilter } from './domain-filter';
|
|
14
|
-
|
|
15
|
-
/** Shorthand aliases for common devices → Playwright device names */
|
|
16
|
-
const DEVICE_ALIASES: Record<string, string> = {
|
|
17
|
-
'iphone': 'iPhone 15',
|
|
18
|
-
'iphone-12': 'iPhone 12',
|
|
19
|
-
'iphone-13': 'iPhone 13',
|
|
20
|
-
'iphone-14': 'iPhone 14',
|
|
21
|
-
'iphone-15': 'iPhone 15',
|
|
22
|
-
'iphone-14-pro': 'iPhone 14 Pro Max',
|
|
23
|
-
'iphone-15-pro': 'iPhone 15 Pro Max',
|
|
24
|
-
'iphone-16': 'iPhone 16',
|
|
25
|
-
'iphone-16-pro': 'iPhone 16 Pro',
|
|
26
|
-
'iphone-16-pro-max': 'iPhone 16 Pro Max',
|
|
27
|
-
'iphone-17': 'iPhone 17',
|
|
28
|
-
'iphone-17-pro': 'iPhone 17 Pro',
|
|
29
|
-
'iphone-17-pro-max': 'iPhone 17 Pro Max',
|
|
30
|
-
'iphone-se': 'iPhone SE',
|
|
31
|
-
'pixel': 'Pixel 7',
|
|
32
|
-
'pixel-7': 'Pixel 7',
|
|
33
|
-
'pixel-5': 'Pixel 5',
|
|
34
|
-
'samsung': 'Galaxy S9+',
|
|
35
|
-
'galaxy': 'Galaxy S9+',
|
|
36
|
-
'ipad': 'iPad (gen 7)',
|
|
37
|
-
'ipad-pro': 'iPad Pro 11',
|
|
38
|
-
'ipad-mini': 'iPad Mini',
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** Custom device descriptors for devices not yet in Playwright's built-in list */
|
|
42
|
-
const CUSTOM_DEVICES: Record<string, DeviceDescriptor> = {
|
|
43
|
-
'iPhone 16': {
|
|
44
|
-
viewport: { width: 393, height: 852 },
|
|
45
|
-
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',
|
|
46
|
-
deviceScaleFactor: 3,
|
|
47
|
-
isMobile: true,
|
|
48
|
-
hasTouch: true,
|
|
49
|
-
},
|
|
50
|
-
'iPhone 16 Pro': {
|
|
51
|
-
viewport: { width: 402, height: 874 },
|
|
52
|
-
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',
|
|
53
|
-
deviceScaleFactor: 3,
|
|
54
|
-
isMobile: true,
|
|
55
|
-
hasTouch: true,
|
|
56
|
-
},
|
|
57
|
-
'iPhone 16 Pro Max': {
|
|
58
|
-
viewport: { width: 440, height: 956 },
|
|
59
|
-
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',
|
|
60
|
-
deviceScaleFactor: 3,
|
|
61
|
-
isMobile: true,
|
|
62
|
-
hasTouch: true,
|
|
63
|
-
},
|
|
64
|
-
'iPhone 17': {
|
|
65
|
-
viewport: { width: 393, height: 852 },
|
|
66
|
-
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',
|
|
67
|
-
deviceScaleFactor: 3,
|
|
68
|
-
isMobile: true,
|
|
69
|
-
hasTouch: true,
|
|
70
|
-
},
|
|
71
|
-
'iPhone 17 Pro': {
|
|
72
|
-
viewport: { width: 402, height: 874 },
|
|
73
|
-
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',
|
|
74
|
-
deviceScaleFactor: 3,
|
|
75
|
-
isMobile: true,
|
|
76
|
-
hasTouch: true,
|
|
77
|
-
},
|
|
78
|
-
'iPhone 17 Pro Max': {
|
|
79
|
-
viewport: { width: 440, height: 956 },
|
|
80
|
-
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',
|
|
81
|
-
deviceScaleFactor: 3,
|
|
82
|
-
isMobile: true,
|
|
83
|
-
hasTouch: true,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export interface DeviceDescriptor {
|
|
88
|
-
viewport: { width: number; height: number };
|
|
89
|
-
userAgent: string;
|
|
90
|
-
deviceScaleFactor: number;
|
|
91
|
-
isMobile: boolean;
|
|
92
|
-
hasTouch: boolean;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Resolve a device name (alias or Playwright name or custom) to a descriptor, or null */
|
|
96
|
-
export function resolveDevice(name: string): DeviceDescriptor | null {
|
|
97
|
-
// Check aliases first (case-insensitive)
|
|
98
|
-
const alias = DEVICE_ALIASES[name.toLowerCase()];
|
|
99
|
-
const aliasTarget = alias || name;
|
|
100
|
-
|
|
101
|
-
// Check custom devices
|
|
102
|
-
if (CUSTOM_DEVICES[aliasTarget]) {
|
|
103
|
-
return CUSTOM_DEVICES[aliasTarget];
|
|
104
|
-
}
|
|
105
|
-
// Direct Playwright device name lookup
|
|
106
|
-
if (playwrightDevices[aliasTarget]) {
|
|
107
|
-
return playwrightDevices[aliasTarget] as DeviceDescriptor;
|
|
108
|
-
}
|
|
109
|
-
// Fuzzy: try case-insensitive match across both lists
|
|
110
|
-
const lower = name.toLowerCase();
|
|
111
|
-
for (const [key, desc] of Object.entries(CUSTOM_DEVICES)) {
|
|
112
|
-
if (key.toLowerCase() === lower) return desc;
|
|
113
|
-
}
|
|
114
|
-
for (const [key, desc] of Object.entries(playwrightDevices)) {
|
|
115
|
-
if (key.toLowerCase() === lower) {
|
|
116
|
-
return desc as DeviceDescriptor;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** List all available device names */
|
|
123
|
-
export function listDevices(): string[] {
|
|
124
|
-
const all = new Set([
|
|
125
|
-
...Object.keys(CUSTOM_DEVICES),
|
|
126
|
-
...Object.keys(playwrightDevices),
|
|
127
|
-
]);
|
|
128
|
-
return [...all].sort();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export class BrowserManager {
|
|
132
|
-
private browser: Browser | null = null;
|
|
133
|
-
private context: BrowserContext | null = null;
|
|
134
|
-
private pages: Map<number, Page> = new Map();
|
|
135
|
-
private activeTabId: number = 0;
|
|
136
|
-
private nextTabId: number = 1;
|
|
137
|
-
private extraHeaders: Record<string, string> = {};
|
|
138
|
-
private customUserAgent: string | null = null;
|
|
139
|
-
private currentDevice: DeviceDescriptor | null = null;
|
|
140
|
-
|
|
141
|
-
// ─── iframe targeting ─────────────────────────────────────
|
|
142
|
-
private activeFramePerTab: Map<number, string> = new Map();
|
|
143
|
-
|
|
144
|
-
// ─── Per-session buffers ──────────────────────────────────
|
|
145
|
-
private buffers: SessionBuffers;
|
|
146
|
-
|
|
147
|
-
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
|
|
148
|
-
// Refs are scoped per tab — switching tabs invalidates refs from the previous tab.
|
|
149
|
-
private refMap: Map<string, Locator> = new Map();
|
|
150
|
-
private refTabId: number = 0; // Which tab the current refs belong to
|
|
151
|
-
|
|
152
|
-
// ─── Last Snapshot (for snapshot-diff) ─────────────────────
|
|
153
|
-
// Per-tab so snapshot-diff compares the correct baseline after tab switches
|
|
154
|
-
private tabSnapshots: Map<number, { text: string; opts: string[] }> = new Map();
|
|
155
|
-
|
|
156
|
-
// ─── Dialog Handling ──────────────────────────────────────
|
|
157
|
-
private lastDialog: { type: string; message: string; defaultValue?: string } | null = null;
|
|
158
|
-
private autoDialogAction: 'accept' | 'dismiss' = 'dismiss';
|
|
159
|
-
private dialogPromptValue: string | undefined;
|
|
160
|
-
|
|
161
|
-
// ─── Network Correlation ────────────────────────────────────
|
|
162
|
-
private requestEntryMap = new WeakMap<PlaywrightRequest, NetworkEntry>();
|
|
163
|
-
|
|
164
|
-
// ─── Offline Mode ─────────────────────────────────────────
|
|
165
|
-
private offline = false;
|
|
166
|
-
|
|
167
|
-
// ─── HAR Recording ────────────────────────────────────────
|
|
168
|
-
private harRecording: HarRecording | null = null;
|
|
169
|
-
|
|
170
|
-
// ─── Video Recording ────────────────────────────────────────
|
|
171
|
-
private videoRecording: { dir: string; startedAt: number } | null = null;
|
|
172
|
-
|
|
173
|
-
// ─── Init Script (domain filter JS injection) ─────────────
|
|
174
|
-
private initScript: string | null = null;
|
|
175
|
-
|
|
176
|
-
// ─── User Routes (survive context recreation) ─────────────
|
|
177
|
-
private userRoutes: Array<{pattern: string; action: 'block' | 'fulfill'; status?: number; body?: string}> = [];
|
|
178
|
-
|
|
179
|
-
// ─── Domain Filter (survive context recreation) ───────────
|
|
180
|
-
private domainFilter: DomainFilter | null = null;
|
|
181
|
-
|
|
182
|
-
// Whether this instance owns (and should close) the Browser process
|
|
183
|
-
private ownsBrowser = false;
|
|
184
|
-
|
|
185
|
-
constructor(buffers?: SessionBuffers) {
|
|
186
|
-
this.buffers = buffers || new SessionBuffers();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
getBuffers(): SessionBuffers {
|
|
190
|
-
return this.buffers;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
getBrowser(): Browser | null {
|
|
194
|
-
return this.browser;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
getContext(): BrowserContext | null {
|
|
198
|
-
return this.context;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Launch a new Chromium browser (single-session / multi-process mode).
|
|
203
|
-
* This instance owns the browser and will close it on close().
|
|
204
|
-
*/
|
|
205
|
-
async launch(onCrash?: () => void) {
|
|
206
|
-
this.browser = await chromium.launch({ headless: true });
|
|
207
|
-
this.ownsBrowser = true;
|
|
208
|
-
|
|
209
|
-
// Chromium crash → notify caller (server uses this to exit; tests ignore it)
|
|
210
|
-
this.browser.on('disconnected', () => {
|
|
211
|
-
if (onCrash) onCrash();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
this.context = await this.browser.newContext({
|
|
215
|
-
viewport: { width: 1920, height: 1080 },
|
|
216
|
-
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// Create first tab
|
|
220
|
-
await this.newTab();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Attach to an existing Browser instance (session multiplexing mode).
|
|
225
|
-
* Creates a new BrowserContext on the shared browser.
|
|
226
|
-
* This instance does NOT own the browser — close() only closes the context.
|
|
227
|
-
*/
|
|
228
|
-
async launchWithBrowser(browser: Browser) {
|
|
229
|
-
this.browser = browser;
|
|
230
|
-
this.ownsBrowser = false;
|
|
231
|
-
|
|
232
|
-
this.context = await browser.newContext({
|
|
233
|
-
viewport: { width: 1920, height: 1080 },
|
|
234
|
-
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// Create first tab
|
|
238
|
-
await this.newTab();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async close() {
|
|
242
|
-
// Close all pages first
|
|
243
|
-
for (const [, page] of this.pages) {
|
|
244
|
-
await page.close().catch(() => {});
|
|
245
|
-
}
|
|
246
|
-
this.pages.clear();
|
|
247
|
-
this.tabSnapshots.clear();
|
|
248
|
-
this.refMap.clear();
|
|
249
|
-
|
|
250
|
-
if (this.context) {
|
|
251
|
-
await this.context.close().catch(() => {});
|
|
252
|
-
this.context = null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (this.ownsBrowser && this.browser) {
|
|
256
|
-
// Remove disconnect handler to avoid exit during intentional close
|
|
257
|
-
this.browser.removeAllListeners('disconnected');
|
|
258
|
-
await this.browser.close();
|
|
259
|
-
this.browser = null;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
isHealthy(): boolean {
|
|
264
|
-
return this.browser !== null && this.browser.isConnected();
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ─── Tab Management ────────────────────────────────────────
|
|
268
|
-
async newTab(url?: string): Promise<number> {
|
|
269
|
-
if (!this.context) throw new Error('Browser not launched');
|
|
270
|
-
|
|
271
|
-
const page = await this.context.newPage();
|
|
272
|
-
|
|
273
|
-
// Wire up console/network capture before navigation so we capture everything
|
|
274
|
-
this.wirePageEvents(page);
|
|
275
|
-
|
|
276
|
-
// Navigate before committing the tab — if goto fails, close page and rethrow
|
|
277
|
-
if (url) {
|
|
278
|
-
try {
|
|
279
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
280
|
-
} catch (err) {
|
|
281
|
-
await page.close().catch(() => {});
|
|
282
|
-
throw err;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Only commit tab state after successful creation + navigation
|
|
287
|
-
const id = this.nextTabId++;
|
|
288
|
-
this.pages.set(id, page);
|
|
289
|
-
this.activeTabId = id;
|
|
290
|
-
|
|
291
|
-
return id;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async closeTab(id?: number): Promise<void> {
|
|
295
|
-
const tabId = id ?? this.activeTabId;
|
|
296
|
-
const page = this.pages.get(tabId);
|
|
297
|
-
if (!page) throw new Error(`Tab ${tabId} not found`);
|
|
298
|
-
|
|
299
|
-
await page.close();
|
|
300
|
-
this.pages.delete(tabId);
|
|
301
|
-
this.tabSnapshots.delete(tabId);
|
|
302
|
-
|
|
303
|
-
// Switch to another tab if we closed the active one
|
|
304
|
-
if (tabId === this.activeTabId) {
|
|
305
|
-
const remaining = [...this.pages.keys()];
|
|
306
|
-
if (remaining.length > 0) {
|
|
307
|
-
this.activeTabId = remaining[remaining.length - 1];
|
|
308
|
-
} else {
|
|
309
|
-
// No tabs left — create a new blank one
|
|
310
|
-
await this.newTab();
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
switchTab(id: number): void {
|
|
316
|
-
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
|
317
|
-
this.activeTabId = id;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
getActiveTabId(): number {
|
|
321
|
-
return this.activeTabId;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
hasTab(id: number): boolean {
|
|
325
|
-
return this.pages.has(id);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
getTabCount(): number {
|
|
329
|
-
return this.pages.size;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> {
|
|
333
|
-
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
|
334
|
-
for (const [id, page] of this.pages) {
|
|
335
|
-
tabs.push({
|
|
336
|
-
id,
|
|
337
|
-
url: page.url(),
|
|
338
|
-
title: '', // title requires await, populated by caller
|
|
339
|
-
active: id === this.activeTabId,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
return tabs;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
|
|
346
|
-
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
|
347
|
-
for (const [id, page] of this.pages) {
|
|
348
|
-
tabs.push({
|
|
349
|
-
id,
|
|
350
|
-
url: page.url(),
|
|
351
|
-
title: await page.title().catch(() => ''),
|
|
352
|
-
active: id === this.activeTabId,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
return tabs;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// ─── Page Access ───────────────────────────────────────────
|
|
359
|
-
getPage(): Page {
|
|
360
|
-
const page = this.pages.get(this.activeTabId);
|
|
361
|
-
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
|
|
362
|
-
return page;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
getPageById(id: number): Page | undefined {
|
|
366
|
-
return this.pages.get(id);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
getCurrentUrl(): string {
|
|
370
|
-
try {
|
|
371
|
-
return this.getPage().url();
|
|
372
|
-
} catch {
|
|
373
|
-
return 'about:blank';
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ─── iframe Targeting ──────────────────────────────────────
|
|
378
|
-
/**
|
|
379
|
-
* Set the active frame by CSS selector (e.g., '#my-iframe', 'iframe[name="content"]').
|
|
380
|
-
* Subsequent commands that use resolveRef, getLocatorRoot, or getFrameContext
|
|
381
|
-
* will target this frame's content instead of the main page.
|
|
382
|
-
*/
|
|
383
|
-
setFrame(selector: string) {
|
|
384
|
-
this.activeFramePerTab.set(this.activeTabId, selector);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Reset to main frame — clears the active frame selector for the current tab.
|
|
389
|
-
*/
|
|
390
|
-
resetFrame() {
|
|
391
|
-
this.activeFramePerTab.delete(this.activeTabId);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Get the current active frame selector, or null if targeting main page.
|
|
396
|
-
*/
|
|
397
|
-
getActiveFrameSelector(): string | null {
|
|
398
|
-
return this.activeFramePerTab.get(this.activeTabId) ?? null;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Get a FrameLocator for the active frame.
|
|
403
|
-
* Returns null if no frame is active (targeting main page).
|
|
404
|
-
*/
|
|
405
|
-
getFrameLocator(): FrameLocator | null {
|
|
406
|
-
const sel = this.getActiveFrameSelector();
|
|
407
|
-
if (!sel) return null;
|
|
408
|
-
return this.getPage().frameLocator(sel);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Get the Frame object for the active frame (needed for evaluate() calls).
|
|
413
|
-
* Returns null if no frame is active.
|
|
414
|
-
* Unlike FrameLocator, Frame supports evaluate(), querySelector, etc.
|
|
415
|
-
*/
|
|
416
|
-
async getFrameContext(): Promise<Frame | null> {
|
|
417
|
-
const sel = this.getActiveFrameSelector();
|
|
418
|
-
if (!sel) return null;
|
|
419
|
-
const page = this.getPage();
|
|
420
|
-
const frameEl = page.locator(sel);
|
|
421
|
-
const handle = await frameEl.elementHandle({ timeout: 5000 });
|
|
422
|
-
if (!handle) throw new Error(`Frame element not found: ${sel}`);
|
|
423
|
-
const frame = await handle.contentFrame();
|
|
424
|
-
if (!frame) throw new Error(`Cannot access content of frame: ${sel}`);
|
|
425
|
-
return frame;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Get a locator root scoped to the active frame (if any) or the page.
|
|
430
|
-
* Use this to create locators that respect the current frame context.
|
|
431
|
-
* Example: bm.getLocatorRoot().locator('button.submit')
|
|
432
|
-
*/
|
|
433
|
-
getLocatorRoot(): Page | FrameLocator {
|
|
434
|
-
const sel = this.getActiveFrameSelector();
|
|
435
|
-
if (sel) {
|
|
436
|
-
return this.getPage().frameLocator(sel);
|
|
437
|
-
}
|
|
438
|
-
return this.getPage();
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// ─── Ref Map ──────────────────────────────────────────────
|
|
442
|
-
setRefMap(refs: Map<string, Locator>) {
|
|
443
|
-
this.refMap = refs;
|
|
444
|
-
this.refTabId = this.activeTabId;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
clearRefs() {
|
|
448
|
-
this.refMap.clear();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
|
|
453
|
-
* Returns { locator } for refs or { selector } for CSS selectors.
|
|
454
|
-
*
|
|
455
|
-
* When a frame is active and a CSS selector is passed, returns { locator }
|
|
456
|
-
* scoped to the frame instead of { selector }, so callers automatically
|
|
457
|
-
* interact with elements inside the iframe.
|
|
458
|
-
*/
|
|
459
|
-
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
|
460
|
-
if (selector.startsWith('@e')) {
|
|
461
|
-
// Refs are scoped to the tab that created them — reject cross-tab usage
|
|
462
|
-
if (this.refTabId !== this.activeTabId) {
|
|
463
|
-
throw new Error(
|
|
464
|
-
`Refs were created on tab ${this.refTabId}, but active tab is ${this.activeTabId}. ` +
|
|
465
|
-
`Run 'snapshot' on the current tab to get fresh refs.`
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
const ref = selector.slice(1); // "e3"
|
|
469
|
-
const locator = this.refMap.get(ref);
|
|
470
|
-
if (!locator) {
|
|
471
|
-
throw new Error(
|
|
472
|
-
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
return { locator };
|
|
476
|
-
}
|
|
477
|
-
// When a frame is active, scope CSS selectors through the frame
|
|
478
|
-
const frameSel = this.getActiveFrameSelector();
|
|
479
|
-
if (frameSel) {
|
|
480
|
-
const frame = this.getPage().frameLocator(frameSel);
|
|
481
|
-
return { locator: frame.locator(selector) };
|
|
482
|
-
}
|
|
483
|
-
return { selector };
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Resolve a ref with staleness detection. Throws immediately if the ref's
|
|
488
|
-
* element no longer exists in the DOM, instead of waiting for action timeout.
|
|
489
|
-
*/
|
|
490
|
-
async resolveRefChecked(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
|
491
|
-
const resolved = this.resolveRef(selector);
|
|
492
|
-
if ('locator' in resolved) {
|
|
493
|
-
const count = await resolved.locator.count();
|
|
494
|
-
if (count === 0) {
|
|
495
|
-
throw new Error(
|
|
496
|
-
`Ref ${selector} is stale (element no longer exists). Re-run 'snapshot' to get fresh refs.`
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
return resolved;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
getRefCount(): number {
|
|
504
|
-
return this.refMap.size;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
getRefMap(): Map<string, Locator> {
|
|
508
|
-
return this.refMap;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Check if refs are valid for the currently active tab.
|
|
513
|
-
* Used by screenshot --annotate to avoid cross-tab ref leaks.
|
|
514
|
-
*/
|
|
515
|
-
areRefsValidForActiveTab(): boolean {
|
|
516
|
-
return this.refMap.size > 0 && this.refTabId === this.activeTabId;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
setLastSnapshot(text: string, opts?: string[]) {
|
|
520
|
-
this.tabSnapshots.set(this.activeTabId, { text, opts: opts || [] });
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
getLastSnapshot(): string | null {
|
|
524
|
-
return this.tabSnapshots.get(this.activeTabId)?.text ?? null;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
getLastSnapshotOpts(): string[] {
|
|
528
|
-
return this.tabSnapshots.get(this.activeTabId)?.opts ?? [];
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
getLastDialog(): { type: string; message: string; defaultValue?: string } | null {
|
|
532
|
-
return this.lastDialog;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
setAutoDialogAction(action: 'accept' | 'dismiss', promptValue?: string) {
|
|
536
|
-
this.autoDialogAction = action;
|
|
537
|
-
this.dialogPromptValue = promptValue;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
getAutoDialogAction(): 'accept' | 'dismiss' {
|
|
541
|
-
return this.autoDialogAction;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// ─── Viewport ──────────────────────────────────────────────
|
|
545
|
-
async setViewport(width: number, height: number) {
|
|
546
|
-
await this.getPage().setViewportSize({ width, height });
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ─── Extra Headers ─────────────────────────────────────────
|
|
550
|
-
async setExtraHeader(name: string, value: string) {
|
|
551
|
-
this.extraHeaders[name] = value;
|
|
552
|
-
if (this.context) {
|
|
553
|
-
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// ─── User Agent ────────────────────────────────────────────
|
|
558
|
-
setUserAgent(ua: string) {
|
|
559
|
-
this.customUserAgent = ua || null;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
getUserAgent(): string | null {
|
|
563
|
-
return this.customUserAgent;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Recreate the browser context with new options.
|
|
568
|
-
* Playwright requires a new context to change UA/device — existing pages are closed.
|
|
569
|
-
*
|
|
570
|
-
* Preserves: cookies, all tab URLs (not just active), active tab selection.
|
|
571
|
-
* Cannot preserve: localStorage/sessionStorage (bound to old context).
|
|
572
|
-
*/
|
|
573
|
-
private async recreateContext(contextOptions: Record<string, any>): Promise<void> {
|
|
574
|
-
if (!this.browser) return;
|
|
575
|
-
|
|
576
|
-
// Auto-inject recordVideo when video recording is active (so emulateDevice/applyUserAgent pass it through)
|
|
577
|
-
if (this.videoRecording && !contextOptions.recordVideo) {
|
|
578
|
-
contextOptions = {
|
|
579
|
-
...contextOptions,
|
|
580
|
-
recordVideo: {
|
|
581
|
-
dir: this.videoRecording.dir,
|
|
582
|
-
size: contextOptions.viewport || this.currentDevice?.viewport || { width: 1920, height: 1080 },
|
|
583
|
-
},
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Save all tab URLs and which tab was active
|
|
588
|
-
const tabUrls: Array<{ id: number; url: string; active: boolean }> = [];
|
|
589
|
-
for (const [id, page] of this.pages) {
|
|
590
|
-
tabUrls.push({
|
|
591
|
-
id,
|
|
592
|
-
url: page.url(),
|
|
593
|
-
active: id === this.activeTabId,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Save cookies from old context
|
|
598
|
-
const savedCookies = this.context ? await this.context.cookies() : [];
|
|
599
|
-
|
|
600
|
-
// Create new context FIRST — if this fails, old session is untouched
|
|
601
|
-
const newContext = await this.browser.newContext(contextOptions);
|
|
602
|
-
|
|
603
|
-
// Restore cookies and headers into new context before creating tabs
|
|
604
|
-
try {
|
|
605
|
-
if (savedCookies.length > 0) {
|
|
606
|
-
await newContext.addCookies(savedCookies);
|
|
607
|
-
}
|
|
608
|
-
if (Object.keys(this.extraHeaders).length > 0) {
|
|
609
|
-
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
|
610
|
-
}
|
|
611
|
-
if (this.offline) {
|
|
612
|
-
await newContext.setOffline(true);
|
|
613
|
-
}
|
|
614
|
-
if (this.initScript) {
|
|
615
|
-
await newContext.addInitScript(this.initScript);
|
|
616
|
-
}
|
|
617
|
-
// Re-apply user routes FIRST
|
|
618
|
-
for (const r of this.userRoutes) {
|
|
619
|
-
if (r.action === 'block') {
|
|
620
|
-
await newContext.route(r.pattern, (route) => route.abort('blockedbyclient'));
|
|
621
|
-
} else {
|
|
622
|
-
await newContext.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
// Re-apply domain filter route LAST (Playwright: last registered = checked first)
|
|
626
|
-
if (this.domainFilter) {
|
|
627
|
-
const df = this.domainFilter;
|
|
628
|
-
await newContext.route('**/*', (route) => {
|
|
629
|
-
const url = route.request().url();
|
|
630
|
-
if (df.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
} catch (err) {
|
|
634
|
-
await newContext.close().catch(() => {});
|
|
635
|
-
throw err;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// Save all mutable state before swapping — needed for rollback
|
|
639
|
-
const oldContext = this.context;
|
|
640
|
-
const oldPages = new Map(this.pages);
|
|
641
|
-
const oldActiveTabId = this.activeTabId;
|
|
642
|
-
const oldNextTabId = this.nextTabId;
|
|
643
|
-
const oldTabSnapshots = new Map(this.tabSnapshots);
|
|
644
|
-
const oldRefMap = new Map(this.refMap);
|
|
645
|
-
const oldFramePerTab = new Map(this.activeFramePerTab);
|
|
646
|
-
|
|
647
|
-
// Swap to new context
|
|
648
|
-
this.context = newContext;
|
|
649
|
-
this.pages.clear();
|
|
650
|
-
this.nextTabId = 1;
|
|
651
|
-
this.refMap.clear();
|
|
652
|
-
|
|
653
|
-
// Recreate all tabs in new context, building old→new ID map for snapshot migration
|
|
654
|
-
const idMap = new Map<number, number>(); // oldTabId → newTabId
|
|
655
|
-
let activeRestoredId: number | null = null;
|
|
656
|
-
try {
|
|
657
|
-
for (const tab of tabUrls) {
|
|
658
|
-
const url = tab.url !== 'about:blank' ? tab.url : undefined;
|
|
659
|
-
const newId = await this.newTab(url);
|
|
660
|
-
idMap.set(tab.id, newId);
|
|
661
|
-
if (tab.active) {
|
|
662
|
-
activeRestoredId = newId;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
if (tabUrls.length === 0) {
|
|
667
|
-
await this.newTab();
|
|
668
|
-
} else if (activeRestoredId !== null) {
|
|
669
|
-
this.activeTabId = activeRestoredId;
|
|
670
|
-
}
|
|
671
|
-
} catch (err) {
|
|
672
|
-
// Full rollback — restore all mutable state including snapshots
|
|
673
|
-
for (const [, page] of this.pages) {
|
|
674
|
-
await page.close().catch(() => {});
|
|
675
|
-
}
|
|
676
|
-
await newContext.close().catch(() => {});
|
|
677
|
-
this.context = oldContext;
|
|
678
|
-
this.pages = oldPages;
|
|
679
|
-
this.activeTabId = oldActiveTabId;
|
|
680
|
-
this.nextTabId = oldNextTabId;
|
|
681
|
-
this.tabSnapshots = oldTabSnapshots;
|
|
682
|
-
this.refMap = oldRefMap;
|
|
683
|
-
this.activeFramePerTab = oldFramePerTab;
|
|
684
|
-
throw err;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Migrate tabSnapshots: remap old tab IDs to new tab IDs
|
|
688
|
-
this.tabSnapshots.clear();
|
|
689
|
-
for (const [oldId, snapshot] of oldTabSnapshots) {
|
|
690
|
-
const newId = idMap.get(oldId);
|
|
691
|
-
if (newId !== undefined) {
|
|
692
|
-
this.tabSnapshots.set(newId, snapshot);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Migrate activeFramePerTab: remap old tab IDs to new tab IDs
|
|
697
|
-
const oldFrames = new Map(this.activeFramePerTab);
|
|
698
|
-
this.activeFramePerTab.clear();
|
|
699
|
-
for (const [oldId, sel] of oldFrames) {
|
|
700
|
-
const newId = idMap.get(oldId);
|
|
701
|
-
if (newId !== undefined) {
|
|
702
|
-
this.activeFramePerTab.set(newId, sel);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// Success — close old pages and context
|
|
707
|
-
for (const [, page] of oldPages) {
|
|
708
|
-
await page.close().catch(() => {});
|
|
709
|
-
}
|
|
710
|
-
if (oldContext) {
|
|
711
|
-
await oldContext.close().catch(() => {});
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
async applyUserAgent(): Promise<void> {
|
|
716
|
-
if (!this.customUserAgent) return;
|
|
717
|
-
await this.recreateContext({
|
|
718
|
-
viewport: this.currentDevice?.viewport || { width: 1920, height: 1080 },
|
|
719
|
-
userAgent: this.customUserAgent,
|
|
720
|
-
...(this.currentDevice ? {
|
|
721
|
-
deviceScaleFactor: this.currentDevice.deviceScaleFactor,
|
|
722
|
-
isMobile: this.currentDevice.isMobile,
|
|
723
|
-
hasTouch: this.currentDevice.hasTouch,
|
|
724
|
-
} : {}),
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Emulate a device — recreates context with full device settings.
|
|
730
|
-
* Pass null to reset to desktop defaults.
|
|
731
|
-
*/
|
|
732
|
-
async emulateDevice(device: DeviceDescriptor | null): Promise<void> {
|
|
733
|
-
// Save state before mutation so we can rollback on failure
|
|
734
|
-
const prevDevice = this.currentDevice;
|
|
735
|
-
const prevUA = this.customUserAgent;
|
|
736
|
-
|
|
737
|
-
this.currentDevice = device;
|
|
738
|
-
if (device) {
|
|
739
|
-
this.customUserAgent = device.userAgent;
|
|
740
|
-
try {
|
|
741
|
-
await this.recreateContext({
|
|
742
|
-
viewport: device.viewport,
|
|
743
|
-
userAgent: device.userAgent,
|
|
744
|
-
deviceScaleFactor: device.deviceScaleFactor,
|
|
745
|
-
isMobile: device.isMobile,
|
|
746
|
-
hasTouch: device.hasTouch,
|
|
747
|
-
});
|
|
748
|
-
} catch (err) {
|
|
749
|
-
// Rollback device/UA state on failure
|
|
750
|
-
this.currentDevice = prevDevice;
|
|
751
|
-
this.customUserAgent = prevUA;
|
|
752
|
-
throw err;
|
|
753
|
-
}
|
|
754
|
-
} else {
|
|
755
|
-
// Reset to desktop
|
|
756
|
-
this.customUserAgent = null;
|
|
757
|
-
try {
|
|
758
|
-
await this.recreateContext({
|
|
759
|
-
viewport: { width: 1920, height: 1080 },
|
|
760
|
-
});
|
|
761
|
-
} catch (err) {
|
|
762
|
-
this.currentDevice = prevDevice;
|
|
763
|
-
this.customUserAgent = prevUA;
|
|
764
|
-
throw err;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
getCurrentDevice(): DeviceDescriptor | null {
|
|
770
|
-
return this.currentDevice;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// ─── Offline Mode ──────────────────────────────────────────
|
|
774
|
-
isOffline(): boolean {
|
|
775
|
-
return this.offline;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
async setOffline(value: boolean): Promise<void> {
|
|
779
|
-
this.offline = value;
|
|
780
|
-
if (this.context) {
|
|
781
|
-
await this.context.setOffline(value);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// ─── HAR Recording ────────────────────────────────────────
|
|
786
|
-
startHarRecording(): void {
|
|
787
|
-
this.harRecording = { startTime: Date.now(), active: true };
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
stopHarRecording(): HarRecording | null {
|
|
791
|
-
const recording = this.harRecording;
|
|
792
|
-
this.harRecording = null;
|
|
793
|
-
return recording;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
getHarRecording(): HarRecording | null {
|
|
797
|
-
return this.harRecording;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// ─── Video Recording ──────────────────────────────────────
|
|
801
|
-
|
|
802
|
-
async startVideoRecording(dir: string): Promise<void> {
|
|
803
|
-
if (this.videoRecording) throw new Error('Video recording already active');
|
|
804
|
-
const fs = await import('fs');
|
|
805
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
806
|
-
|
|
807
|
-
this.videoRecording = { dir, startedAt: Date.now() };
|
|
808
|
-
const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
|
|
809
|
-
await this.recreateContext({
|
|
810
|
-
viewport,
|
|
811
|
-
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
812
|
-
...(this.currentDevice ? {
|
|
813
|
-
deviceScaleFactor: this.currentDevice.deviceScaleFactor,
|
|
814
|
-
isMobile: this.currentDevice.isMobile,
|
|
815
|
-
hasTouch: this.currentDevice.hasTouch,
|
|
816
|
-
} : {}),
|
|
817
|
-
recordVideo: { dir, size: viewport },
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
async stopVideoRecording(): Promise<{ dir: string; startedAt: number; paths: string[] } | null> {
|
|
822
|
-
if (!this.videoRecording) return null;
|
|
823
|
-
|
|
824
|
-
const recording = this.videoRecording;
|
|
825
|
-
// Collect video objects before pages are closed by recreateContext
|
|
826
|
-
const videos: Array<{ video: any; tabId: number }> = [];
|
|
827
|
-
for (const [id, page] of this.pages) {
|
|
828
|
-
const video = page.video();
|
|
829
|
-
if (video) videos.push({ video, tabId: id });
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Clear state BEFORE recreateContext so auto-injection doesn't add recordVideo
|
|
833
|
-
this.videoRecording = null;
|
|
834
|
-
|
|
835
|
-
const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
|
|
836
|
-
await this.recreateContext({
|
|
837
|
-
viewport,
|
|
838
|
-
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
839
|
-
...(this.currentDevice ? {
|
|
840
|
-
deviceScaleFactor: this.currentDevice.deviceScaleFactor,
|
|
841
|
-
isMobile: this.currentDevice.isMobile,
|
|
842
|
-
hasTouch: this.currentDevice.hasTouch,
|
|
843
|
-
} : {}),
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
// Save videos with predictable names (saveAs works for both local and remote CDP)
|
|
847
|
-
const paths: string[] = [];
|
|
848
|
-
for (const { video, tabId } of videos) {
|
|
849
|
-
const target = `${recording.dir}/tab-${tabId}.webm`;
|
|
850
|
-
await video.saveAs(target);
|
|
851
|
-
paths.push(target);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
return { dir: recording.dir, startedAt: recording.startedAt, paths };
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
getVideoRecording(): { dir: string; startedAt: number } | null {
|
|
858
|
-
return this.videoRecording;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// ─── Init Script ───────────────────────────────────────────
|
|
862
|
-
setInitScript(script: string): void {
|
|
863
|
-
this.initScript = script;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
getInitScript(): string | null {
|
|
867
|
-
return this.initScript;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// ─── User Routes ──────────────────────────────────────────
|
|
871
|
-
addUserRoute(pattern: string, action: 'block' | 'fulfill', status?: number, body?: string) {
|
|
872
|
-
this.userRoutes.push({pattern, action, status, body});
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
clearUserRoutes() {
|
|
876
|
-
this.userRoutes = [];
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
getUserRoutes() {
|
|
880
|
-
return this.userRoutes;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// ─── Domain Filter ────────────────────────────────────────
|
|
884
|
-
setDomainFilter(filter: DomainFilter) {
|
|
885
|
-
this.domainFilter = filter;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
getDomainFilter(): DomainFilter | null {
|
|
889
|
-
return this.domainFilter;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/**
|
|
893
|
-
* Reverse-lookup: find the tab ID that owns this page.
|
|
894
|
-
* Returns undefined if the page isn't committed to any tab yet (during newTab).
|
|
895
|
-
*/
|
|
896
|
-
private getTabIdForPage(page: Page): number | undefined {
|
|
897
|
-
for (const [id, p] of this.pages) {
|
|
898
|
-
if (p === page) return id;
|
|
899
|
-
}
|
|
900
|
-
return undefined;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// ─── Console/Network/Ref Wiring ────────────────────────────
|
|
904
|
-
private wirePageEvents(page: Page) {
|
|
905
|
-
// Clear ref map on navigation — but ONLY if this page belongs to the tab
|
|
906
|
-
// that owns the current refs. Otherwise, navigating tab B clears tab A's refs.
|
|
907
|
-
page.on('framenavigated', (frame) => {
|
|
908
|
-
if (frame === page.mainFrame()) {
|
|
909
|
-
const tabId = this.getTabIdForPage(page);
|
|
910
|
-
if (tabId !== undefined && tabId === this.refTabId) {
|
|
911
|
-
this.clearRefs();
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
page.on('dialog', async (dialog) => {
|
|
917
|
-
this.lastDialog = {
|
|
918
|
-
type: dialog.type(),
|
|
919
|
-
message: dialog.message(),
|
|
920
|
-
defaultValue: dialog.defaultValue() || undefined,
|
|
921
|
-
};
|
|
922
|
-
this.buffers.addConsoleEntry({
|
|
923
|
-
timestamp: Date.now(),
|
|
924
|
-
level: 'info',
|
|
925
|
-
text: `[dialog] ${dialog.type()}: ${dialog.message()}`,
|
|
926
|
-
});
|
|
927
|
-
if (this.autoDialogAction === 'accept') {
|
|
928
|
-
// Use user-supplied prompt value if set, otherwise fall back to browser default
|
|
929
|
-
await dialog.accept(this.dialogPromptValue ?? dialog.defaultValue());
|
|
930
|
-
} else {
|
|
931
|
-
await dialog.dismiss();
|
|
932
|
-
}
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
page.on('console', (msg) => {
|
|
936
|
-
this.buffers.addConsoleEntry({
|
|
937
|
-
timestamp: Date.now(),
|
|
938
|
-
level: msg.type(),
|
|
939
|
-
text: msg.text(),
|
|
940
|
-
});
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
page.on('request', (req) => {
|
|
944
|
-
const entry: NetworkEntry = {
|
|
945
|
-
timestamp: Date.now(),
|
|
946
|
-
method: req.method(),
|
|
947
|
-
url: req.url(),
|
|
948
|
-
};
|
|
949
|
-
this.buffers.addNetworkEntry(entry);
|
|
950
|
-
// Store direct reference for accurate correlation on duplicate URLs
|
|
951
|
-
this.requestEntryMap.set(req, entry);
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
page.on('response', (res) => {
|
|
955
|
-
const entry = this.requestEntryMap.get(res.request());
|
|
956
|
-
if (entry) {
|
|
957
|
-
entry.status = res.status();
|
|
958
|
-
entry.duration = Date.now() - entry.timestamp;
|
|
959
|
-
}
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
// Capture response sizes via Content-Length header (avoids reading full body into memory)
|
|
963
|
-
page.on('requestfinished', async (req) => {
|
|
964
|
-
try {
|
|
965
|
-
const res = await req.response();
|
|
966
|
-
if (res) {
|
|
967
|
-
const entry = this.requestEntryMap.get(req);
|
|
968
|
-
if (entry) {
|
|
969
|
-
const cl = res.headers()['content-length'];
|
|
970
|
-
entry.size = cl ? parseInt(cl, 10) : 0;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
} catch {}
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
}
|