@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.
- package/BENCHMARKS.md +222 -0
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/browse.ts +2 -0
- package/package.json +54 -0
- package/skill/SKILL.md +301 -0
- package/src/browser-manager.ts +687 -0
- package/src/buffers.ts +81 -0
- package/src/bun.d.ts +47 -0
- package/src/cli.ts +442 -0
- package/src/commands/meta.ts +358 -0
- package/src/commands/read.ts +304 -0
- package/src/commands/write.ts +259 -0
- package/src/constants.ts +12 -0
- package/src/diff.d.ts +12 -0
- package/src/install-skill.ts +98 -0
- package/src/server.ts +325 -0
- package/src/session-manager.ts +121 -0
- package/src/snapshot.ts +497 -0
- package/src/types.ts +12 -0
|
@@ -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
|
+
}
|