@underactive/pi-topping-web-tools 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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +63 -0
- package/src/fetch-markdown.ts +694 -0
- package/src/pdf-extract.ts +565 -0
- package/src/permissions.ts +127 -0
- package/src/web-browser/browser-manager.ts +1008 -0
- package/src/web-browser/index.ts +1033 -0
- package/src/web-browser/permissions.ts +162 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped Playwright browser manager.
|
|
3
|
+
*
|
|
4
|
+
* Lazily launches a single headless Chromium instance. `context.pages()` is
|
|
5
|
+
* the source of truth for open tabs; `this.page` is only an "active tab"
|
|
6
|
+
* cursor that new pages (including popups) claim automatically. Page
|
|
7
|
+
* interactions are serialized via an async mutex.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Browser, BrowserContext, Cookie, Locator, Page } from "playwright";
|
|
11
|
+
import { chromium } from "playwright";
|
|
12
|
+
import { getTurndownService } from "../fetch-markdown.ts";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
15
|
+
export const DEFAULT_VIEWPORT_HEIGHT = 720;
|
|
16
|
+
export const MAX_FULL_PAGE_WIDTH = 1920;
|
|
17
|
+
export const MAX_BUFFER_ENTRIES = 500;
|
|
18
|
+
export const DEFAULT_NAVIGATION_TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
type AbortableOpts = { timeout?: number; signal?: AbortSignal };
|
|
21
|
+
type FrameOpts = { frame?: string };
|
|
22
|
+
|
|
23
|
+
function parsePngDimensions(buffer: Buffer): { width: number; height: number } | undefined {
|
|
24
|
+
if (buffer.length < 24) return undefined;
|
|
25
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50) return undefined;
|
|
26
|
+
return {
|
|
27
|
+
width: buffer.readUInt32BE(16),
|
|
28
|
+
height: buffer.readUInt32BE(20),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ConsoleEntry = {
|
|
33
|
+
type: "log" | "error" | "pageerror";
|
|
34
|
+
text: string;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type NetworkEntry = {
|
|
39
|
+
type: "failed" | "error_response";
|
|
40
|
+
url: string;
|
|
41
|
+
method: string;
|
|
42
|
+
status: number;
|
|
43
|
+
statusText: string;
|
|
44
|
+
failure?: string;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DialogEntry = {
|
|
49
|
+
type: string;
|
|
50
|
+
message: string;
|
|
51
|
+
defaultValue: string;
|
|
52
|
+
action: "accept" | "dismiss";
|
|
53
|
+
timestamp: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type TabInfo = {
|
|
57
|
+
index: number;
|
|
58
|
+
url: string;
|
|
59
|
+
title: string;
|
|
60
|
+
active: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type DialogBehavior = {
|
|
64
|
+
action: "accept" | "dismiss";
|
|
65
|
+
promptText?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type SetCookieParam = {
|
|
69
|
+
name: string;
|
|
70
|
+
value: string;
|
|
71
|
+
url?: string;
|
|
72
|
+
domain?: string;
|
|
73
|
+
path?: string;
|
|
74
|
+
expires?: number;
|
|
75
|
+
httpOnly?: boolean;
|
|
76
|
+
secure?: boolean;
|
|
77
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type PageInfo = {
|
|
81
|
+
url: string;
|
|
82
|
+
title: string;
|
|
83
|
+
isOpen: boolean;
|
|
84
|
+
consoleCount: number;
|
|
85
|
+
networkCount: number;
|
|
86
|
+
dialogCount: number;
|
|
87
|
+
tabCount: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type NavigateResult = {
|
|
91
|
+
url: string;
|
|
92
|
+
title: string;
|
|
93
|
+
statusCode?: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type ScreenshotResult = {
|
|
97
|
+
data: string;
|
|
98
|
+
width: number;
|
|
99
|
+
height: number;
|
|
100
|
+
fullPage: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Async mutex that serializes browser operations.
|
|
105
|
+
*
|
|
106
|
+
* NOTE: `close()` and `forceClose()` bypass this mutex so shutdown is
|
|
107
|
+
* never blocked by a queued or in-flight operation.
|
|
108
|
+
*/
|
|
109
|
+
export class AsyncMutex {
|
|
110
|
+
private chain: Promise<void> = Promise.resolve();
|
|
111
|
+
|
|
112
|
+
/** Wait for the operation queue to drain (no in-flight operations). */
|
|
113
|
+
async drain(): Promise<void> {
|
|
114
|
+
await this.chain;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
118
|
+
let release!: () => void;
|
|
119
|
+
const wait = new Promise<void>((resolve) => {
|
|
120
|
+
release = resolve;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const run = this.chain.then(fn);
|
|
124
|
+
this.chain = run.then(() => wait, () => wait);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
return await run;
|
|
128
|
+
} finally {
|
|
129
|
+
release();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class BrowserManager {
|
|
135
|
+
private browser: Browser | undefined;
|
|
136
|
+
private context: BrowserContext | undefined;
|
|
137
|
+
private page: Page | undefined;
|
|
138
|
+
private consoleBuffer: ConsoleEntry[] = [];
|
|
139
|
+
private networkBuffer: NetworkEntry[] = [];
|
|
140
|
+
private dialogBuffer: DialogEntry[] = [];
|
|
141
|
+
private dialogBehavior: DialogBehavior = { action: "dismiss" };
|
|
142
|
+
private readonly mutex = new AsyncMutex();
|
|
143
|
+
private closed = false; // true only after explicit close(), not on initial/normal state
|
|
144
|
+
|
|
145
|
+
get isOpen(): boolean {
|
|
146
|
+
return !this.closed && !!this.page;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getPageInfoAsync(): Promise<PageInfo> {
|
|
150
|
+
return this.mutex.run(async () => {
|
|
151
|
+
if (!this.isOpen) {
|
|
152
|
+
return {
|
|
153
|
+
url: "",
|
|
154
|
+
title: "",
|
|
155
|
+
isOpen: false,
|
|
156
|
+
consoleCount: this.consoleBuffer.length,
|
|
157
|
+
networkCount: this.networkBuffer.length,
|
|
158
|
+
dialogCount: this.dialogBuffer.length,
|
|
159
|
+
tabCount: 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const page = this.page!;
|
|
164
|
+
let title = "";
|
|
165
|
+
try {
|
|
166
|
+
title = await page.title();
|
|
167
|
+
} catch {
|
|
168
|
+
// fall back to empty title
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
url: page.url(),
|
|
173
|
+
title,
|
|
174
|
+
isOpen: true,
|
|
175
|
+
consoleCount: this.consoleBuffer.length,
|
|
176
|
+
networkCount: this.networkBuffer.length,
|
|
177
|
+
dialogCount: this.dialogBuffer.length,
|
|
178
|
+
tabCount: this.context?.pages().filter((p) => !p.isClosed()).length ?? 1,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async launch(): Promise<void> {
|
|
184
|
+
return this.mutex.run(async () => {
|
|
185
|
+
await this.ensureLaunched();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async navigate(url: string, opts?: AbortableOpts): Promise<NavigateResult> {
|
|
190
|
+
return this.mutex.run(async () => {
|
|
191
|
+
await this.ensureLaunched();
|
|
192
|
+
const page = this.page!;
|
|
193
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
194
|
+
|
|
195
|
+
const response = await this.withAbort(opts?.signal, async () =>
|
|
196
|
+
page.goto(url, {
|
|
197
|
+
timeout,
|
|
198
|
+
waitUntil: "domcontentloaded",
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
url: page.url(),
|
|
204
|
+
title: await page.title(),
|
|
205
|
+
statusCode: response?.status(),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async screenshot(
|
|
211
|
+
opts?: AbortableOpts &
|
|
212
|
+
FrameOpts & {
|
|
213
|
+
fullPage?: boolean;
|
|
214
|
+
selector?: string;
|
|
215
|
+
},
|
|
216
|
+
): Promise<ScreenshotResult> {
|
|
217
|
+
return this.mutex.run(async () => {
|
|
218
|
+
await this.ensureLaunched();
|
|
219
|
+
const page = this.page!;
|
|
220
|
+
const fullPage = opts?.fullPage ?? false;
|
|
221
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
222
|
+
|
|
223
|
+
const originalViewport = page.viewportSize();
|
|
224
|
+
const needsResize =
|
|
225
|
+
fullPage &&
|
|
226
|
+
(!originalViewport ||
|
|
227
|
+
originalViewport.width !== DEFAULT_VIEWPORT_WIDTH ||
|
|
228
|
+
originalViewport.height !== DEFAULT_VIEWPORT_HEIGHT);
|
|
229
|
+
if (needsResize) {
|
|
230
|
+
await page.setViewportSize({ width: DEFAULT_VIEWPORT_WIDTH, height: DEFAULT_VIEWPORT_HEIGHT });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const buffer = await this.withAbort(opts?.signal, async () => {
|
|
234
|
+
if (opts?.selector) {
|
|
235
|
+
const locator = this.resolveLocator(page, opts.selector, opts.frame).first();
|
|
236
|
+
await locator.waitFor({ state: "visible", timeout });
|
|
237
|
+
return locator.screenshot({ type: "png", timeout });
|
|
238
|
+
}
|
|
239
|
+
return page.screenshot({
|
|
240
|
+
type: "png",
|
|
241
|
+
fullPage,
|
|
242
|
+
timeout,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const viewport = page.viewportSize() ?? {
|
|
247
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
248
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
let width: number;
|
|
252
|
+
let height: number;
|
|
253
|
+
if (fullPage && !opts?.selector) {
|
|
254
|
+
const dims = parsePngDimensions(buffer);
|
|
255
|
+
if (dims) {
|
|
256
|
+
width = dims.width > MAX_FULL_PAGE_WIDTH ? MAX_FULL_PAGE_WIDTH : dims.width;
|
|
257
|
+
height = dims.height;
|
|
258
|
+
} else {
|
|
259
|
+
width = Math.min(viewport.width, MAX_FULL_PAGE_WIDTH);
|
|
260
|
+
height = viewport.height;
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
width = viewport.width;
|
|
264
|
+
height = viewport.height;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (needsResize && originalViewport) {
|
|
268
|
+
await page.setViewportSize(originalViewport);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
data: buffer.toString("base64"),
|
|
273
|
+
width,
|
|
274
|
+
height,
|
|
275
|
+
fullPage,
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async click(selector: string, opts?: AbortableOpts & FrameOpts): Promise<string> {
|
|
281
|
+
return this.mutex.run(async () => {
|
|
282
|
+
await this.ensureLaunched();
|
|
283
|
+
const page = this.page!;
|
|
284
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
285
|
+
|
|
286
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
287
|
+
try {
|
|
288
|
+
await this.withAbort(opts?.signal, async () => locator.first().click({ timeout }));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if ((await locator.count()) === 0) {
|
|
291
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
return `Clicked "${selector}"`;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async fill(
|
|
300
|
+
selector: string,
|
|
301
|
+
text: string,
|
|
302
|
+
opts?: AbortableOpts & FrameOpts & { mode?: "fill" | "press"; delay?: number },
|
|
303
|
+
): Promise<string> {
|
|
304
|
+
return this.mutex.run(async () => {
|
|
305
|
+
await this.ensureLaunched();
|
|
306
|
+
const page = this.page!;
|
|
307
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
308
|
+
|
|
309
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
310
|
+
const target = locator.first();
|
|
311
|
+
try {
|
|
312
|
+
await this.withAbort(opts?.signal, async () => {
|
|
313
|
+
if (opts?.mode === "press") {
|
|
314
|
+
await target.pressSequentially(text, { timeout, delay: opts?.delay ?? 50 });
|
|
315
|
+
} else {
|
|
316
|
+
await target.fill(text, { timeout });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if ((await locator.count()) === 0) {
|
|
321
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
return `Typed into "${selector}"`;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async hover(selector: string, opts?: AbortableOpts & FrameOpts): Promise<string> {
|
|
330
|
+
return this.mutex.run(async () => {
|
|
331
|
+
await this.ensureLaunched();
|
|
332
|
+
const page = this.page!;
|
|
333
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
334
|
+
|
|
335
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
336
|
+
try {
|
|
337
|
+
await this.withAbort(opts?.signal, async () => locator.first().hover({ timeout }));
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if ((await locator.count()) === 0) {
|
|
340
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
341
|
+
}
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
return `Hovered "${selector}"`;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async press(key: string, opts?: AbortableOpts): Promise<string> {
|
|
349
|
+
return this.mutex.run(async () => {
|
|
350
|
+
await this.ensureLaunched();
|
|
351
|
+
const page = this.page!;
|
|
352
|
+
|
|
353
|
+
await this.withAbort(opts?.signal, async () => page.keyboard.press(key));
|
|
354
|
+
return `Pressed "${key}"`;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async selectOption(
|
|
359
|
+
selector: string,
|
|
360
|
+
values: string[],
|
|
361
|
+
opts?: AbortableOpts & FrameOpts,
|
|
362
|
+
): Promise<string> {
|
|
363
|
+
return this.mutex.run(async () => {
|
|
364
|
+
await this.ensureLaunched();
|
|
365
|
+
const page = this.page!;
|
|
366
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
367
|
+
|
|
368
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
369
|
+
try {
|
|
370
|
+
await this.withAbort(opts?.signal, async () =>
|
|
371
|
+
locator.first().selectOption(values, { timeout }),
|
|
372
|
+
);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if ((await locator.count()) === 0) {
|
|
375
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
return `Selected ${values.join(", ")} in "${selector}"`;
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async setViewport(width: number, height: number, opts?: AbortableOpts): Promise<string> {
|
|
384
|
+
return this.mutex.run(async () => {
|
|
385
|
+
await this.ensureLaunched();
|
|
386
|
+
const page = this.page!;
|
|
387
|
+
|
|
388
|
+
await this.withAbort(opts?.signal, async () => page.setViewportSize({ width, height }));
|
|
389
|
+
return `Viewport set to ${width}×${height}`;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async getText(selector?: string, opts?: AbortableOpts & FrameOpts): Promise<string> {
|
|
394
|
+
return this.mutex.run(async () => {
|
|
395
|
+
await this.ensureLaunched();
|
|
396
|
+
const page = this.page!;
|
|
397
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
398
|
+
const locator = this.resolveLocator(page, selector ?? "body", opts?.frame).first();
|
|
399
|
+
return this.withAbort(opts?.signal, async () => locator.innerText({ timeout }));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async getMarkdown(opts?: AbortableOpts): Promise<string> {
|
|
404
|
+
return this.mutex.run(async () => {
|
|
405
|
+
await this.ensureLaunched();
|
|
406
|
+
const page = this.page!;
|
|
407
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
408
|
+
await this.withAbort(opts?.signal, async () => {
|
|
409
|
+
await page.waitForLoadState("domcontentloaded", { timeout });
|
|
410
|
+
});
|
|
411
|
+
const html = await page.evaluate(() => document.body.innerHTML);
|
|
412
|
+
const svc = getTurndownService();
|
|
413
|
+
return svc.turndown(html);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async getAccessibilitySnapshot(
|
|
418
|
+
opts?: AbortableOpts & FrameOpts & { selector?: string },
|
|
419
|
+
): Promise<string> {
|
|
420
|
+
return this.mutex.run(async () => {
|
|
421
|
+
await this.ensureLaunched();
|
|
422
|
+
const page = this.page!;
|
|
423
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
424
|
+
const selector = opts?.selector ?? "body";
|
|
425
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
426
|
+
try {
|
|
427
|
+
const snapshot = await this.withAbort(opts?.signal, async () =>
|
|
428
|
+
locator.first().ariaSnapshot({ timeout }),
|
|
429
|
+
);
|
|
430
|
+
if (!snapshot) {
|
|
431
|
+
return "No accessibility snapshot available.";
|
|
432
|
+
}
|
|
433
|
+
return snapshot;
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if ((await locator.count()) === 0) {
|
|
436
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
437
|
+
}
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async waitFor(
|
|
444
|
+
opts: AbortableOpts &
|
|
445
|
+
FrameOpts & {
|
|
446
|
+
selector?: string;
|
|
447
|
+
state?: "visible" | "hidden" | "attached" | "detached";
|
|
448
|
+
networkidle?: boolean;
|
|
449
|
+
},
|
|
450
|
+
): Promise<string> {
|
|
451
|
+
return this.mutex.run(async () => {
|
|
452
|
+
await this.ensureLaunched();
|
|
453
|
+
const page = this.page!;
|
|
454
|
+
const timeout = opts.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
455
|
+
|
|
456
|
+
if (opts.selector) {
|
|
457
|
+
const locator = this.resolveLocator(page, opts.selector, opts.frame);
|
|
458
|
+
try {
|
|
459
|
+
await this.withAbort(opts.signal, async () =>
|
|
460
|
+
locator.first().waitFor({ state: opts.state ?? "visible", timeout }),
|
|
461
|
+
);
|
|
462
|
+
} catch (err) {
|
|
463
|
+
if ((await locator.count()) === 0) {
|
|
464
|
+
return await this.selectorError(page, opts.selector, opts.frame);
|
|
465
|
+
}
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (opts.networkidle) {
|
|
471
|
+
await this.withAbort(opts.signal, async () =>
|
|
472
|
+
page.waitForLoadState("networkidle", { timeout }),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const parts: string[] = [];
|
|
477
|
+
if (opts.selector) {
|
|
478
|
+
parts.push(`selector "${opts.selector}" is ${opts.state ?? "visible"}`);
|
|
479
|
+
}
|
|
480
|
+
if (opts.networkidle) {
|
|
481
|
+
parts.push("network idle");
|
|
482
|
+
}
|
|
483
|
+
return `Wait satisfied: ${parts.join(", ")}`;
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async scroll(
|
|
488
|
+
opts?: AbortableOpts & FrameOpts & { selector?: string; deltaX?: number; deltaY?: number },
|
|
489
|
+
): Promise<string> {
|
|
490
|
+
return this.mutex.run(async () => {
|
|
491
|
+
await this.ensureLaunched();
|
|
492
|
+
const page = this.page!;
|
|
493
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
494
|
+
|
|
495
|
+
if (opts?.selector) {
|
|
496
|
+
const locator = this.resolveLocator(page, opts.selector, opts.frame);
|
|
497
|
+
try {
|
|
498
|
+
await this.withAbort(opts.signal, async () =>
|
|
499
|
+
locator.first().scrollIntoViewIfNeeded({ timeout }),
|
|
500
|
+
);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if ((await locator.count()) === 0) {
|
|
503
|
+
return await this.selectorError(page, opts.selector, opts.frame);
|
|
504
|
+
}
|
|
505
|
+
throw err;
|
|
506
|
+
}
|
|
507
|
+
return `Scrolled "${opts.selector}" into view`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const viewport = page.viewportSize() ?? {
|
|
511
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
512
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
513
|
+
};
|
|
514
|
+
const deltaX = opts?.deltaX ?? 0;
|
|
515
|
+
const deltaY = opts?.deltaY ?? viewport.height;
|
|
516
|
+
await this.withAbort(opts?.signal, async () => page.mouse.wheel(deltaX, deltaY));
|
|
517
|
+
return `Scrolled by (${deltaX}, ${deltaY})`;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async uploadFile(
|
|
522
|
+
selector: string,
|
|
523
|
+
files: string[],
|
|
524
|
+
opts?: AbortableOpts & FrameOpts,
|
|
525
|
+
): Promise<string> {
|
|
526
|
+
return this.mutex.run(async () => {
|
|
527
|
+
await this.ensureLaunched();
|
|
528
|
+
const page = this.page!;
|
|
529
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
530
|
+
|
|
531
|
+
const locator = this.resolveLocator(page, selector, opts?.frame);
|
|
532
|
+
try {
|
|
533
|
+
await this.withAbort(opts?.signal, async () =>
|
|
534
|
+
locator.first().setInputFiles(files, { timeout }),
|
|
535
|
+
);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if ((await locator.count()) === 0) {
|
|
538
|
+
return await this.selectorError(page, selector, opts?.frame);
|
|
539
|
+
}
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
return `Uploaded ${files.length} file(s) to "${selector}"`;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async drag(
|
|
547
|
+
sourceSelector: string,
|
|
548
|
+
targetSelector: string,
|
|
549
|
+
opts?: AbortableOpts & FrameOpts,
|
|
550
|
+
): Promise<string> {
|
|
551
|
+
return this.mutex.run(async () => {
|
|
552
|
+
await this.ensureLaunched();
|
|
553
|
+
const page = this.page!;
|
|
554
|
+
const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
|
|
555
|
+
|
|
556
|
+
const sourceLocator = this.resolveLocator(page, sourceSelector, opts?.frame);
|
|
557
|
+
const targetLocator = this.resolveLocator(page, targetSelector, opts?.frame);
|
|
558
|
+
try {
|
|
559
|
+
await this.withAbort(opts?.signal, async () =>
|
|
560
|
+
sourceLocator.first().dragTo(targetLocator.first(), { timeout }),
|
|
561
|
+
);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if ((await sourceLocator.count()) === 0) {
|
|
564
|
+
return await this.selectorError(page, sourceSelector, opts?.frame);
|
|
565
|
+
}
|
|
566
|
+
if ((await targetLocator.count()) === 0) {
|
|
567
|
+
return await this.selectorError(page, targetSelector, opts?.frame);
|
|
568
|
+
}
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
return `Dragged "${sourceSelector}" to "${targetSelector}"`;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async listTabs(): Promise<TabInfo[]> {
|
|
576
|
+
return this.mutex.run(async () => {
|
|
577
|
+
await this.ensureLaunched();
|
|
578
|
+
const pages = this.context!.pages().filter((p) => !p.isClosed());
|
|
579
|
+
const titles = await Promise.all(pages.map((p) => p.title().catch(() => "")));
|
|
580
|
+
return pages.map((p, i) => ({
|
|
581
|
+
index: i,
|
|
582
|
+
url: p.url(),
|
|
583
|
+
title: titles[i]!,
|
|
584
|
+
active: p === this.page,
|
|
585
|
+
}));
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async switchTab(index: number): Promise<string> {
|
|
590
|
+
return this.mutex.run(async () => {
|
|
591
|
+
await this.ensureLaunched();
|
|
592
|
+
const pages = this.context!.pages().filter((p) => !p.isClosed());
|
|
593
|
+
if (index < 0 || index >= pages.length) {
|
|
594
|
+
return `Invalid tab index ${index}. ${pages.length} tab(s) open (0–${Math.max(0, pages.length - 1)}).`;
|
|
595
|
+
}
|
|
596
|
+
this.page = pages[index];
|
|
597
|
+
await this.page!.bringToFront();
|
|
598
|
+
return `Switched to tab ${index}: ${this.page!.url()}`;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
setDialogBehavior(action: "accept" | "dismiss", promptText?: string): void {
|
|
603
|
+
this.dialogBehavior = { action, promptText };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
drainDialogs(): DialogEntry[] {
|
|
607
|
+
const entries = [...this.dialogBuffer];
|
|
608
|
+
this.dialogBuffer = [];
|
|
609
|
+
return entries;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async evaluate(expression: string, opts?: { signal?: AbortSignal }): Promise<unknown> {
|
|
613
|
+
return this.mutex.run(async () => {
|
|
614
|
+
await this.ensureLaunched();
|
|
615
|
+
const page = this.page!;
|
|
616
|
+
return this.withAbort(opts?.signal, async () => page.evaluate(expression));
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async content(opts?: { signal?: AbortSignal }): Promise<string> {
|
|
621
|
+
return this.mutex.run(async () => {
|
|
622
|
+
await this.ensureLaunched();
|
|
623
|
+
return this.withAbort(opts?.signal, async () => this.page!.content());
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async getCookies(opts?: { signal?: AbortSignal }): Promise<Cookie[]> {
|
|
628
|
+
return this.mutex.run(async () => {
|
|
629
|
+
await this.ensureLaunched();
|
|
630
|
+
return this.withAbort(opts?.signal, async () => this.context!.cookies());
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async setCookies(cookies: SetCookieParam[], opts?: { signal?: AbortSignal }): Promise<void> {
|
|
635
|
+
return this.mutex.run(async () => {
|
|
636
|
+
await this.ensureLaunched();
|
|
637
|
+
await this.withAbort(opts?.signal, async () => this.context!.addCookies(cookies));
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
drainConsole(): ConsoleEntry[] {
|
|
642
|
+
const entries = [...this.consoleBuffer];
|
|
643
|
+
this.consoleBuffer = [];
|
|
644
|
+
return entries;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
drainNetwork(): NetworkEntry[] {
|
|
648
|
+
const entries = [...this.networkBuffer];
|
|
649
|
+
this.networkBuffer = [];
|
|
650
|
+
return entries;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async goBack(opts?: AbortableOpts): Promise<string> {
|
|
654
|
+
return this.mutex.run(async () => {
|
|
655
|
+
await this.ensureLaunched();
|
|
656
|
+
const response = await this.withAbort(opts?.signal, async () =>
|
|
657
|
+
this.page!.goBack({ waitUntil: "domcontentloaded" }),
|
|
658
|
+
);
|
|
659
|
+
return response ? `Navigated back to ${this.page!.url()}` : "No history to go back";
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async goForward(opts?: AbortableOpts): Promise<string> {
|
|
664
|
+
return this.mutex.run(async () => {
|
|
665
|
+
await this.ensureLaunched();
|
|
666
|
+
const response = await this.withAbort(opts?.signal, async () =>
|
|
667
|
+
this.page!.goForward({ waitUntil: "domcontentloaded" }),
|
|
668
|
+
);
|
|
669
|
+
return response ? `Navigated forward to ${this.page!.url()}` : "No history to go forward";
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async reload(opts?: AbortableOpts): Promise<string> {
|
|
674
|
+
return this.mutex.run(async () => {
|
|
675
|
+
await this.ensureLaunched();
|
|
676
|
+
await this.withAbort(opts?.signal, async () =>
|
|
677
|
+
this.page!.reload({ waitUntil: "domcontentloaded" }),
|
|
678
|
+
);
|
|
679
|
+
return `Reloaded ${this.page!.url()}`;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Close the browser, bypassing the mutex so shutdown is never blocked
|
|
685
|
+
* by queued or in-flight operations.
|
|
686
|
+
*
|
|
687
|
+
* This is critical for `/reload` compatibility: the Pi runtime awaits all
|
|
688
|
+
* `session_shutdown` handlers synchronously. If this hangs, reload hangs.
|
|
689
|
+
*
|
|
690
|
+
* @param timeoutMs — max ms to wait for Chromium processes to exit
|
|
691
|
+
* (default 5000). Set to 0 for unbounded.
|
|
692
|
+
*/
|
|
693
|
+
async close(timeoutMs = 5_000): Promise<void> {
|
|
694
|
+
// Mark closed immediately so ensureLaunched() bails out.
|
|
695
|
+
this.closed = true;
|
|
696
|
+
|
|
697
|
+
// Drain the mutex chain (wait for any in-flight operation to finish),
|
|
698
|
+
// then call cleanup directly — bypassing the mutex so no new
|
|
699
|
+
// operation can re-trigger ensureLaunched() mid-shutdown.
|
|
700
|
+
await this.mutex.drain();
|
|
701
|
+
|
|
702
|
+
// Force-close with optional timeout.
|
|
703
|
+
await this.forceClose(timeoutMs);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Force-close the browser, bypassing all serialization.
|
|
708
|
+
*
|
|
709
|
+
* If a timeout is provided and cleanup doesn't complete within it,
|
|
710
|
+
* resources are left dangling (the OS will clean up the process) rather
|
|
711
|
+
* than blocking indefinitely.
|
|
712
|
+
*/
|
|
713
|
+
private async forceClose(timeoutMs: number): Promise<void> {
|
|
714
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
715
|
+
const timer =
|
|
716
|
+
timeoutMs > 0
|
|
717
|
+
? Promise.resolve().then(
|
|
718
|
+
() =>
|
|
719
|
+
new Promise<never>((_, reject) => {
|
|
720
|
+
timerId = setTimeout(
|
|
721
|
+
() => reject(new Error("Close timed out")),
|
|
722
|
+
timeoutMs,
|
|
723
|
+
);
|
|
724
|
+
}),
|
|
725
|
+
)
|
|
726
|
+
: Promise.resolve();
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
await Promise.race([this.cleanup(), timer]);
|
|
730
|
+
} catch {
|
|
731
|
+
// Timeout or cleanup error — best effort; resources are cleaned up by OS.
|
|
732
|
+
} finally {
|
|
733
|
+
if (timerId) {
|
|
734
|
+
clearTimeout(timerId);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private async withAbort<T>(signal: AbortSignal | undefined, fn: () => Promise<T>): Promise<T> {
|
|
740
|
+
if (signal?.aborted) {
|
|
741
|
+
throw new DOMException("Aborted", "AbortError");
|
|
742
|
+
}
|
|
743
|
+
if (!signal) {
|
|
744
|
+
return fn();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const op = fn();
|
|
748
|
+
op.catch(() => {});
|
|
749
|
+
|
|
750
|
+
let listener: (() => void) | undefined;
|
|
751
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
752
|
+
listener = () => reject(new DOMException("Aborted", "AbortError"));
|
|
753
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
return await Promise.race([op, abortPromise]);
|
|
758
|
+
} finally {
|
|
759
|
+
if (listener) {
|
|
760
|
+
signal.removeEventListener("abort", listener);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private resolveLocator(page: Page, selector: string, frame?: string): Locator {
|
|
766
|
+
if (frame) {
|
|
767
|
+
return page.frameLocator(frame).locator(selector);
|
|
768
|
+
}
|
|
769
|
+
return page.locator(selector);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private async selectorDiagnostics(page: Page, selector: string, frame?: string): Promise<string> {
|
|
773
|
+
try {
|
|
774
|
+
if (frame) {
|
|
775
|
+
const count = await page.frameLocator(frame).locator(selector).count();
|
|
776
|
+
return `Matches: ${count} (within frame "${frame}")`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return await page.evaluate((sel) => {
|
|
780
|
+
const elements = Array.from(document.querySelectorAll(sel));
|
|
781
|
+
const total = elements.length;
|
|
782
|
+
if (total === 0) {
|
|
783
|
+
return "Matches: 0";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
let visible = 0;
|
|
787
|
+
let hidden = 0;
|
|
788
|
+
for (const el of elements) {
|
|
789
|
+
const htmlEl = el as HTMLElement;
|
|
790
|
+
const style = window.getComputedStyle(htmlEl);
|
|
791
|
+
const isVisible = htmlEl.offsetHeight > 0 && style.visibility !== "hidden";
|
|
792
|
+
if (isVisible) {
|
|
793
|
+
visible++;
|
|
794
|
+
} else {
|
|
795
|
+
hidden++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const samples = elements.slice(0, 5).map((el) => {
|
|
800
|
+
const htmlEl = el as HTMLElement;
|
|
801
|
+
const text = (htmlEl.textContent ?? "").trim().slice(0, 40);
|
|
802
|
+
return `<${el.tagName.toLowerCase()}>${text ? ` "${text}"` : ""}`;
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
return `Matches: ${total} (${visible} visible, ${hidden} hidden). Samples: ${samples.join(", ")}`;
|
|
806
|
+
}, selector);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
809
|
+
return `Diagnostics unavailable (${message})`;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private async selectorError(page: Page, selector: string, frame?: string): Promise<string> {
|
|
814
|
+
const diagnostics = await this.selectorDiagnostics(page, selector, frame);
|
|
815
|
+
const snippet = await this.pageSnippet(page);
|
|
816
|
+
const frameNote = frame ? ` within frame "${frame}"` : "";
|
|
817
|
+
return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${page.url()}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async ensureLaunched(): Promise<void> {
|
|
821
|
+
if (this.page && !this.page.isClosed() && this.browser?.isConnected()) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// The active tab closed (e.g. a popup was dismissed) but the browser
|
|
826
|
+
// and context are still alive — fall back to another open tab
|
|
827
|
+
// instead of tearing down and relaunching.
|
|
828
|
+
if (this.browser?.isConnected() && this.context) {
|
|
829
|
+
const openPages = this.context.pages().filter((p) => !p.isClosed());
|
|
830
|
+
if (openPages.length > 0) {
|
|
831
|
+
this.page = openPages[openPages.length - 1];
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
await this.cleanup();
|
|
837
|
+
|
|
838
|
+
// Short-circuit: don't re-launch after close().
|
|
839
|
+
if (this.closed) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
try {
|
|
844
|
+
this.browser = await chromium.launch({ headless: true });
|
|
845
|
+
this.context = await this.browser.newContext({
|
|
846
|
+
viewport: {
|
|
847
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
848
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
849
|
+
},
|
|
850
|
+
acceptDownloads: false,
|
|
851
|
+
});
|
|
852
|
+
this.context.on("page", (page) => this.registerPage(page));
|
|
853
|
+
this.page = await this.context.newPage();
|
|
854
|
+
this.closed = false;
|
|
855
|
+
} catch (err: unknown) {
|
|
856
|
+
await this.cleanup();
|
|
857
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
858
|
+
if (message.includes("Executable doesn't exist") || message.includes("browserType.launch")) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
"Chromium is not installed. Run: npx playwright install chromium",
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
throw err;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Attaches console/network/dialog listeners to every page in the context
|
|
869
|
+
* (including the initial page and popups) and claims it as the active
|
|
870
|
+
* tab. This is the single attachment point — `context.on("page")` also
|
|
871
|
+
* fires for `context.newPage()`, so listeners must not be attached
|
|
872
|
+
* anywhere else or they would be registered twice.
|
|
873
|
+
*/
|
|
874
|
+
private registerPage(page: Page): void {
|
|
875
|
+
this.attachConsoleListeners(page);
|
|
876
|
+
this.attachDialogListener(page);
|
|
877
|
+
this.page = page;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private attachConsoleListeners(page: Page): void {
|
|
881
|
+
page.on("console", (msg) => {
|
|
882
|
+
this.pushConsole({ type: "log", text: `[${msg.type()}] ${msg.text()}`, timestamp: Date.now() });
|
|
883
|
+
});
|
|
884
|
+
page.on("pageerror", (err) => {
|
|
885
|
+
this.pushConsole({ type: "pageerror", text: err.message, timestamp: Date.now() });
|
|
886
|
+
});
|
|
887
|
+
page.on("requestfailed", (req) => {
|
|
888
|
+
this.pushNetwork({
|
|
889
|
+
type: "failed",
|
|
890
|
+
url: req.url(),
|
|
891
|
+
method: req.method(),
|
|
892
|
+
status: 0,
|
|
893
|
+
statusText: "",
|
|
894
|
+
failure: req.failure()?.errorText ?? "unknown",
|
|
895
|
+
timestamp: Date.now(),
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
page.on("response", (res) => {
|
|
899
|
+
const status = res.status();
|
|
900
|
+
if (status >= 400) {
|
|
901
|
+
this.pushNetwork({
|
|
902
|
+
type: "error_response",
|
|
903
|
+
url: res.url(),
|
|
904
|
+
method: res.request().method(),
|
|
905
|
+
status,
|
|
906
|
+
statusText: res.statusText(),
|
|
907
|
+
timestamp: Date.now(),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Registering a `dialog` listener switches Playwright from auto-dismiss
|
|
915
|
+
* to "must be resolved" — an unhandled dialog freezes all further
|
|
916
|
+
* actions on the page, so this unconditionally accepts or dismisses
|
|
917
|
+
* based on the configured session behavior.
|
|
918
|
+
*/
|
|
919
|
+
private attachDialogListener(page: Page): void {
|
|
920
|
+
page.on("dialog", (dialog) => {
|
|
921
|
+
const entry: DialogEntry = {
|
|
922
|
+
type: dialog.type(),
|
|
923
|
+
message: dialog.message(),
|
|
924
|
+
defaultValue: dialog.defaultValue(),
|
|
925
|
+
action: this.dialogBehavior.action,
|
|
926
|
+
timestamp: Date.now(),
|
|
927
|
+
};
|
|
928
|
+
this.pushDialog(entry);
|
|
929
|
+
|
|
930
|
+
const resolve =
|
|
931
|
+
this.dialogBehavior.action === "accept"
|
|
932
|
+
? dialog.accept(this.dialogBehavior.promptText)
|
|
933
|
+
: dialog.dismiss();
|
|
934
|
+
resolve.catch(() => {
|
|
935
|
+
// dialog may already have been handled/closed
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private pushConsole(entry: ConsoleEntry): void {
|
|
941
|
+
this.consoleBuffer.push(entry);
|
|
942
|
+
if (this.consoleBuffer.length > MAX_BUFFER_ENTRIES) {
|
|
943
|
+
this.consoleBuffer.shift();
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private pushNetwork(entry: NetworkEntry): void {
|
|
948
|
+
this.networkBuffer.push(entry);
|
|
949
|
+
if (this.networkBuffer.length > MAX_BUFFER_ENTRIES) {
|
|
950
|
+
this.networkBuffer.shift();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private pushDialog(entry: DialogEntry): void {
|
|
955
|
+
this.dialogBuffer.push(entry);
|
|
956
|
+
if (this.dialogBuffer.length > MAX_BUFFER_ENTRIES) {
|
|
957
|
+
this.dialogBuffer.shift();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private async pageSnippet(page: Page): Promise<string> {
|
|
962
|
+
return page.evaluate(() => document.documentElement.outerHTML.slice(0, 2000));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private async cleanup(): Promise<void> {
|
|
966
|
+
this.consoleBuffer = [];
|
|
967
|
+
this.networkBuffer = [];
|
|
968
|
+
this.dialogBuffer = [];
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
await this.page?.close({ runBeforeUnload: false });
|
|
972
|
+
} catch {
|
|
973
|
+
// ignore
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
await this.context?.close();
|
|
977
|
+
} catch {
|
|
978
|
+
// ignore
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
await this.browser?.close();
|
|
982
|
+
} catch {
|
|
983
|
+
// ignore
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
this.page = undefined;
|
|
987
|
+
this.context = undefined;
|
|
988
|
+
this.browser = undefined;
|
|
989
|
+
// NOTE: Do NOT set `this.closed` here — only the public `close()`
|
|
990
|
+
// method should mark the manager as closed, so that `ensureLaunched()`
|
|
991
|
+
// can distinguish between "initial/normal cleanup" and "post-shutdown".
|
|
992
|
+
// NOTE: `dialogBehavior` is intentionally NOT reset — it is a session
|
|
993
|
+
// setting, not page state.
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let manager: BrowserManager | undefined;
|
|
998
|
+
|
|
999
|
+
export function getBrowserManager(): BrowserManager {
|
|
1000
|
+
return (manager ??= new BrowserManager());
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
export async function closeBrowserManager(): Promise<void> {
|
|
1004
|
+
if (manager) {
|
|
1005
|
+
await manager.close();
|
|
1006
|
+
manager = undefined;
|
|
1007
|
+
}
|
|
1008
|
+
}
|