pi-lean-portal 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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * Playwright Plugin Base — shared abstract base for Playwright-based browser backends.
3
+ *
4
+ * Implements the full BrowserPlugin interface using Playwright. Subclasses
5
+ * parameterize engine, user-agent, launch args, capabilities, and install hints.
6
+ *
7
+ * Architecture:
8
+ * - All interaction logic (navigate, snapshot, click, type, scroll, etc.) lives here.
9
+ * - Subclasses are thin: ~30 lines overriding name, capabilities, userAgent,
10
+ * launchBrowser(), and installHint.
11
+ * - UA capture (probe-then-cache at lazy browser init) is opt-in via captureUserAgent.
12
+ * - Launch errors are wrapped with engine-specific install hints.
13
+ */
14
+
15
+ import type { Browser, BrowserContext, Page } from "playwright";
16
+ import {
17
+ parseSnapshot,
18
+ buildLocator,
19
+ type AriaCachedNode,
20
+ } from "../../core/shared/accessibility-tree.js";
21
+ import {
22
+ getDialogLog,
23
+ installDialogHandlers,
24
+ getConsoleLog as getRawConsoleLog,
25
+ clearConsoleLog,
26
+ } from "../../core/shared/browser-events.js";
27
+ import { sessionManager } from "../../core/shared/session-manager.js";
28
+ import { checkPage } from "../../core/shared/bot-detection.js";
29
+ import { waitForNavigationSettle } from "../../core/shared/nav-settle.js";
30
+ import { join } from "node:path";
31
+ import { mkdirSync } from "node:fs";
32
+ import { saveStorageState } from "../../core/shared/storage-state.js";
33
+ import type {
34
+ BrowserPlugin,
35
+ PluginCapabilities,
36
+ DialogEvent,
37
+ NavigateResult,
38
+ SnapshotResult,
39
+ InteractionResult,
40
+ ScreenshotResult,
41
+ ConsoleMessagesResult,
42
+ EvaluateResult,
43
+ ResultBase,
44
+ Cookie,
45
+ CookieResult,
46
+ ClearCookiesOptions,
47
+ StorageStateResult,
48
+ } from "../../core/plugin-api.js";
49
+
50
+ // ─── Types ────────────────────────────────────────────────────────
51
+
52
+ /** A per-task page entry: isolated BrowserContext + Page + optional profile name. */
53
+ type PageEntry = {
54
+ context: BrowserContext;
55
+ page: Page;
56
+ profileName?: string;
57
+ };
58
+
59
+ // ─── PlaywrightPluginBase ─────────────────────────────────────────
60
+
61
+ export abstract class PlaywrightPluginBase implements BrowserPlugin {
62
+ // ── Subclass contract ──────────────────────────────────────
63
+
64
+ /** Unique stable identifier (e.g. "chromium", "firefox") */
65
+ abstract readonly name: string;
66
+
67
+ /** Advertised capabilities */
68
+ abstract readonly capabilities: PluginCapabilities;
69
+
70
+ /**
71
+ * Hardcoded fallback user-agent string.
72
+ * Used when `captureUserAgent` is false (Chromium) or as fallback when
73
+ * dynamic capture fails (Firefox).
74
+ */
75
+ protected abstract get userAgent(): string;
76
+
77
+ /**
78
+ * Launch a Playwright browser instance with engine-specific args.
79
+ * Called once at lazy init. Must return a connected Browser.
80
+ * Implementations should use `chromium.launch()` or `firefox.launch()`.
81
+ */
82
+ protected abstract launchBrowser(): Promise<Browser>;
83
+
84
+ /**
85
+ * Engine-specific install hint, shown when the browser executable
86
+ * is not installed. Example: "Run: npx playwright install firefox".
87
+ */
88
+ protected abstract get installHint(): string;
89
+
90
+ /**
91
+ * Set to true to enable UA probe-then-cache at lazy browser init.
92
+ * When true, the base class opens about:blank, reads navigator.userAgent,
93
+ * and caches the result for all subsequent contexts.
94
+ * Chromium keeps this false (uses hardcoded UA).
95
+ */
96
+ protected readonly captureUserAgent: boolean = false;
97
+
98
+ // ── Private state ──────────────────────────────────────────
99
+
100
+ /** Enable structured debug logging via BROWSER_DEBUG env var */
101
+ private readonly _debug = process.env.BROWSER_DEBUG === "1";
102
+
103
+ /** Log a structured debug line to stderr when BROWSER_DEBUG=1 */
104
+ private _log(event: string, data: Record<string, unknown>): void {
105
+ if (this._debug) {
106
+ process.stderr.write(`[browser] ${event}: ${JSON.stringify(data)}\n`);
107
+ }
108
+ }
109
+
110
+ /** Shared browser instance (lazy-initialised) */
111
+ private _browser: Browser | null = null;
112
+
113
+ /** Cached user-agent string after dynamic capture (Firefox) */
114
+ private _cachedUA: string | null = null;
115
+
116
+ /**
117
+ * Per-task context + page tracking.
118
+ * Each task gets its own isolated BrowserContext created fresh per navigate.
119
+ */
120
+ private _pages = new Map<string, PageEntry>();
121
+
122
+ /** Per-task element cache (ref → AriaCachedNode) */
123
+ private _elementCache = new Map<string, Map<string, AriaCachedNode>>();
124
+
125
+ // ── Lifecycle ───────────────────────────────────────────────
126
+
127
+ async init(_config?: Record<string, unknown>): Promise<void> {
128
+ // No config needed — all behavior is hardcoded defaults.
129
+ }
130
+
131
+ async cleanupAll(): Promise<void> {
132
+ // Close all pages — each cleanup() handles page + context lifecycle
133
+ for (const taskId of [...this._pages.keys()]) {
134
+ await this.cleanup(taskId).catch(() => {});
135
+ }
136
+
137
+ if (this._browser) {
138
+ try {
139
+ await this._browser.close();
140
+ } catch {
141
+ /* browser may already be closed */
142
+ }
143
+ this._browser = null;
144
+ }
145
+ }
146
+
147
+ // ── Internal helpers ───────────────────────────────────────
148
+
149
+ /**
150
+ * Effective user-agent for new contexts.
151
+ * Returns the cached UA (if captureUserAgent is true and capture succeeded)
152
+ * or the subclass's hardcoded fallback.
153
+ */
154
+ protected get effectiveUserAgent(): string {
155
+ return this._cachedUA ?? this.userAgent;
156
+ }
157
+
158
+ /**
159
+ * Launch the browser engine with install-error wrapping.
160
+ * If the executable is missing, re-throws with the engine-specific install hint.
161
+ */
162
+ private async _launchWithHint(): Promise<Browser> {
163
+ try {
164
+ return await this.launchBrowser();
165
+ } catch (err: unknown) {
166
+ if (
167
+ err instanceof Error &&
168
+ /Executable doesn't exist|browserType\.launch/i.test(err.message)
169
+ ) {
170
+ throw new Error(this.installHint);
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Probe the user-agent from a throwaway about:blank page.
178
+ * Called once at lazy browser init when `captureUserAgent` is true.
179
+ * Silently falls back to `this.userAgent` on failure.
180
+ */
181
+ private async _captureUA(): Promise<void> {
182
+ if (this._cachedUA) return;
183
+ let page: Page | undefined;
184
+ try {
185
+ page = await this._browser!.newPage();
186
+ this._cachedUA = (await page.evaluate(
187
+ () => navigator.userAgent,
188
+ )) as string;
189
+ } catch {
190
+ // Swallow — fallback to this.userAgent
191
+ } finally {
192
+ if (page) await page.close().catch(() => {});
193
+ }
194
+ }
195
+
196
+ // ── Context lifecycle ────────────────────────────────────
197
+
198
+ /**
199
+ * Get or create a BrowserContext and Page for a task.
200
+ *
201
+ * Always creates a fresh BrowserContext per navigate, closing any
202
+ * existing page/context for the task first. Storage state from disk
203
+ * is applied when a named/session profile is active.
204
+ */
205
+ private async getOrCreateContext(
206
+ taskId: string,
207
+ options?: {
208
+ storageState?: unknown;
209
+ profileName?: string;
210
+ profileMode?: "none" | "session" | "named";
211
+ },
212
+ ): Promise<{
213
+ context: BrowserContext;
214
+ page: Page;
215
+ isNew: boolean;
216
+ }> {
217
+ // 1. Check if task already has a page/context — close it (fresh per navigate)
218
+ const existing = this._pages.get(taskId);
219
+ let savedState: { cookies: unknown[]; origins: unknown[] } | undefined;
220
+ if (existing) {
221
+ // ── Save storage state before closing (persistent profiles) ─
222
+ savedState = await this._persistState(taskId, existing.context).catch(
223
+ () => undefined,
224
+ );
225
+ try {
226
+ await existing.page.close();
227
+ } catch {
228
+ /* page may already be closed */
229
+ }
230
+ try {
231
+ await existing.context.close();
232
+ } catch {
233
+ /* context may already be closed */
234
+ }
235
+ this._pages.delete(taskId);
236
+ this._elementCache.delete(taskId);
237
+ }
238
+
239
+ // 2. Determine effective storage state:
240
+ // - The router may pass pre-loaded state via options.storageState
241
+ // - If not provided, use the state we just saved (in-memory, no disk read)
242
+ // - This ensures a re-navigate immediately picks up cookies set during
243
+ // the just-ended session without waiting for a future disk load.
244
+ const effectiveStorageState = options?.storageState ?? savedState;
245
+
246
+ // 3. Create fresh context
247
+ const context = await this._newBrowserContext(effectiveStorageState);
248
+ const page = await context.newPage();
249
+
250
+ const pageEntry: {
251
+ context: BrowserContext;
252
+ page: Page;
253
+ profileName?: string;
254
+ } = { context, page };
255
+ if (options?.profileName) {
256
+ pageEntry.profileName = options.profileName;
257
+ }
258
+ this._pages.set(taskId, pageEntry);
259
+ installDialogHandlers(taskId, page);
260
+ this._elementCache.set(taskId, new Map());
261
+
262
+ return { context, page, isNew: true };
263
+ }
264
+
265
+ /**
266
+ * Create a new BrowserContext on the shared browser instance.
267
+ * Lazily initialises the shared browser if needed.
268
+ */
269
+ private async _newBrowserContext(
270
+ storageState?: unknown,
271
+ ): Promise<BrowserContext> {
272
+ // Lazy-init the shared browser
273
+ if (!this._browser) {
274
+ this._browser = await this._launchWithHint();
275
+
276
+ // Auto-recover from browser crash/disconnect
277
+ this._browser.on("disconnected", () => {
278
+ this._browser = null;
279
+ for (const tid of this._pages.keys()) {
280
+ sessionManager.updateSession(tid, { crashed: true });
281
+ this._elementCache.delete(tid);
282
+ }
283
+ this._pages.clear();
284
+ });
285
+
286
+ // UA capture at first launch (Firefox opt-in)
287
+ if (this.captureUserAgent) {
288
+ await this._captureUA();
289
+ }
290
+ }
291
+
292
+ const contextOptions: Record<string, unknown> = {
293
+ viewport: { width: 1280, height: 720 },
294
+ userAgent: this.effectiveUserAgent,
295
+ };
296
+
297
+ // Apply storage state (cookies + localStorage) for profile restoration
298
+ if (storageState !== undefined) {
299
+ contextOptions.storageState = storageState;
300
+ }
301
+
302
+ const context = await this._browser.newContext(contextOptions);
303
+
304
+ // Start Playwright trace capture if BROWSER_TRACE_DIR is set.
305
+ const traceDir = process.env.BROWSER_TRACE_DIR;
306
+ if (traceDir) {
307
+ try {
308
+ await context.tracing.start({
309
+ screenshots: true,
310
+ snapshots: true,
311
+ sources: true,
312
+ });
313
+ } catch {
314
+ // Best-effort — trace is diagnostic only
315
+ }
316
+ }
317
+
318
+ return context;
319
+ }
320
+
321
+ private getPage(taskId: string): Page | undefined {
322
+ return this._pages.get(taskId)?.page;
323
+ }
324
+
325
+ /**
326
+ * Returns the page for `taskId` or `null` if there is no active session.
327
+ * Logs a "No active session" debug event when `op` is provided.
328
+ */
329
+ private requirePage(taskId: string, op?: string): Page | null {
330
+ const page = this.getPage(taskId);
331
+ if (!page && op) {
332
+ this._log(op, {
333
+ taskId,
334
+ success: false,
335
+ error: "No active session",
336
+ });
337
+ }
338
+ return page ?? null;
339
+ }
340
+
341
+ /**
342
+ * Returns the full page entry (context + page) for `taskId` or `null`.
343
+ * Logs a "No active session" debug event when `op` is provided.
344
+ * Used by cookie/storage methods that also need the BrowserContext.
345
+ */
346
+ private requireEntry(taskId: string, op?: string): PageEntry | null {
347
+ const entry = this._pages.get(taskId);
348
+ if (!entry && op) {
349
+ this._log(op, {
350
+ taskId,
351
+ success: false,
352
+ error: "No active session",
353
+ });
354
+ }
355
+ return entry ?? null;
356
+ }
357
+
358
+ /**
359
+ * Public interface — returns null when no cache exists (no session yet).
360
+ * Does NOT auto-create an empty cache.
361
+ */
362
+ getElementCache(taskId: string): Map<string, AriaCachedNode> | null {
363
+ return this._elementCache.get(taskId) ?? null;
364
+ }
365
+
366
+ /**
367
+ * Private internal cache accessor — auto-creates an empty cache on miss
368
+ * so internal callers (takeSnapshot, etc.) never need null checks.
369
+ */
370
+ private getOrCreateCache(taskId: string): Map<string, AriaCachedNode> {
371
+ let cache = this._elementCache.get(taskId);
372
+ if (!cache) {
373
+ cache = new Map();
374
+ this._elementCache.set(taskId, cache);
375
+ }
376
+ return cache;
377
+ }
378
+
379
+ /**
380
+ * Save storage state to disk for persistent sessions, returning the
381
+ * raw state so callers can use it immediately (avoiding a disk read
382
+ * for the new context).
383
+ *
384
+ * Best-effort — failures are logged to stderr and swallowed.
385
+ * Checked against `session?.persistState` so non-persistent sessions
386
+ * never trigger disk I/O.
387
+ *
388
+ * @param taskId - The task/session ID.
389
+ * @param context - The Playwright BrowserContext to snapshot.
390
+ * @returns The raw storage state object (cookies + origins), or undefined
391
+ * if the session is non-persistent or the save failed.
392
+ */
393
+ private async _persistState(
394
+ taskId: string,
395
+ context: BrowserContext,
396
+ ): Promise<{ cookies: unknown[]; origins: unknown[] } | undefined> {
397
+ const session = sessionManager.getSession(taskId);
398
+ if (!session?.persistState) return undefined;
399
+
400
+ try {
401
+ const state = await context.storageState();
402
+ const name = session.profileName ?? "default";
403
+ saveStorageState(name, state);
404
+ return state;
405
+ } catch (err) {
406
+ console.warn(
407
+ `[pi-lean-portal] Failed to auto-save storage state for profile ` +
408
+ `'${session.profileName ?? "default"}': ` +
409
+ `${err instanceof Error ? err.message : String(err)}. ` +
410
+ "Session state may be lost.",
411
+ );
412
+ return undefined;
413
+ }
414
+ }
415
+
416
+ /** Take an accessibility snapshot and update the element cache. */
417
+ private async takeSnapshot(
418
+ taskId: string,
419
+ page: Page,
420
+ ): Promise<{
421
+ snapshot: string;
422
+ elementCount: number;
423
+ dialogEvents: DialogEvent[];
424
+ }> {
425
+ try {
426
+ const snap = await page.ariaSnapshot();
427
+ const parsed = parseSnapshot(snap);
428
+
429
+ // Update cache
430
+ this.getOrCreateCache(taskId).clear();
431
+ for (const [ref, node] of parsed.elements) {
432
+ this.getOrCreateCache(taskId).set(ref, node);
433
+ }
434
+
435
+ // Collect recent auto-dismissed dialog events (last 10)
436
+ const rawDialogs = getDialogLog(taskId);
437
+ const dialogEvents = rawDialogs.slice(-10).map((d) => ({
438
+ type: d.type,
439
+ message: d.message,
440
+ handledAs: d.handledAs,
441
+ }));
442
+
443
+ return {
444
+ snapshot: parsed.text,
445
+ elementCount: parsed.count,
446
+ dialogEvents,
447
+ };
448
+ } catch {
449
+ return {
450
+ snapshot: "(snapshot not available)",
451
+ elementCount: 0,
452
+ dialogEvents: [],
453
+ };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Check for bot/anti-automation detection signals via shared utility.
459
+ *
460
+ * Checks the page TITLE against specific challenge phrases (avoids
461
+ * false positives like Wikipedia mentioning "captcha"), and additionally
462
+ * checks the BODY against high-specificity patterns that are unique to
463
+ * CDN block pages (Akamai reference IDs, Cloudflare challenge URLs, etc.).
464
+ */
465
+ private async checkBotDetection(page: Page): Promise<boolean> {
466
+ try {
467
+ const title = await page.title();
468
+ const bodyText = await page.evaluate(
469
+ () => document.body?.innerText || "",
470
+ );
471
+ // Also grab raw HTML to check for CAPTCHA widget embed codes.
472
+ const html = await page.evaluate(
473
+ () => document.documentElement?.innerHTML || "",
474
+ );
475
+ // checkPage handles all three: title (challenge phrases),
476
+ // body (challenge phrases + CDN patterns), and HTML (CAPTCHA embeds).
477
+ return checkPage(title, bodyText, html).isBlocked;
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ // ── Navigation & state ──────────────────────────────────────
484
+
485
+ async navigate(
486
+ url: string,
487
+ taskId: string,
488
+ timeoutMs: number = 30_000,
489
+ options?: {
490
+ signal?: AbortSignal;
491
+ storageState?: unknown;
492
+ profileName?: string;
493
+ profileMode?: "none" | "session" | "named";
494
+ },
495
+ ): Promise<NavigateResult> {
496
+ const _start = performance.now();
497
+ try {
498
+ const ctxOpts: {
499
+ storageState?: unknown;
500
+ profileName?: string;
501
+ profileMode?: "none" | "session" | "named";
502
+ } = {};
503
+ if (options?.storageState !== undefined)
504
+ ctxOpts.storageState = options.storageState;
505
+ if (options?.profileName !== undefined)
506
+ ctxOpts.profileName = options.profileName;
507
+ if (options?.profileMode !== undefined)
508
+ ctxOpts.profileMode = options.profileMode;
509
+ const { page } = await this.getOrCreateContext(taskId, ctxOpts);
510
+
511
+ // Wire up abort
512
+ if (options?.signal) {
513
+ options.signal.addEventListener(
514
+ "abort",
515
+ () => {
516
+ page.close().catch(() => {});
517
+ },
518
+ { once: true },
519
+ );
520
+ }
521
+
522
+ // Navigate (with one retry for transient network errors)
523
+ for (let attempt = 0; attempt < 2; attempt++) {
524
+ try {
525
+ await page.goto(url, {
526
+ // "load" instead of "networkidle" so Cloudflare challenge pages
527
+ // finish loading their HTML; "networkidle" hangs on challenge
528
+ // pages that keep polling via XHR.
529
+ waitUntil: "load",
530
+ timeout: timeoutMs,
531
+ });
532
+ break; // success
533
+ } catch (gotoErr: unknown) {
534
+ const lastError =
535
+ gotoErr instanceof Error ? gotoErr.message : String(gotoErr);
536
+ const isTransient =
537
+ /net::ERR_|ECONNRESET|ECONNREFUSED|ETIMEDOUT|timeout|Interrupted/i.test(
538
+ lastError,
539
+ );
540
+ if (!isTransient || attempt > 0) {
541
+ throw gotoErr;
542
+ }
543
+ await page.waitForTimeout(2000);
544
+ }
545
+ }
546
+
547
+ // Wait for dynamic content to settle
548
+ try {
549
+ await page.waitForFunction(
550
+ () =>
551
+ new Promise<boolean>((resolve) => {
552
+ const count = document.querySelectorAll("*").length;
553
+ setTimeout(() => {
554
+ resolve(
555
+ document.querySelectorAll("*").length === count ||
556
+ count > 5000,
557
+ );
558
+ }, 400);
559
+ }),
560
+ { timeout: 5000 },
561
+ );
562
+ } catch {
563
+ // Stabilization timed out — proceed with whatever is rendered
564
+ }
565
+
566
+ // Check for bot detection (Cloudflare, etc.) — AFTER DOM stabilizes
567
+ // so JS-injected challenge content is present when we check.
568
+ const botDetected = await this.checkBotDetection(page);
569
+
570
+ const title = await page.title();
571
+
572
+ // Take accessibility snapshot + collect dialog events
573
+ const {
574
+ snapshot: snapshotText,
575
+ elementCount,
576
+ dialogEvents,
577
+ } = await this.takeSnapshot(taskId, page);
578
+
579
+ // Update session manager
580
+ sessionManager.updateSession(taskId, {
581
+ currentUrl: page.url(),
582
+ currentTitle: title,
583
+ pluginName: this.name,
584
+ });
585
+
586
+ this._log("navigate", {
587
+ url: page.url(),
588
+ plugin: this.name,
589
+ success: true,
590
+ botDetected: botDetected ?? false,
591
+ elementCount,
592
+ time: Math.round(performance.now() - _start),
593
+ });
594
+
595
+ return {
596
+ success: true,
597
+ url: page.url(),
598
+ title,
599
+ snapshot: snapshotText,
600
+ elementCount,
601
+ botDetected,
602
+ dialogEvents,
603
+ };
604
+ } catch (err: unknown) {
605
+ const msg = err instanceof Error ? err.message : String(err);
606
+
607
+ // Try to check page content even on error — challenge pages may have
608
+ // loaded their HTML before the timeout. Without this, Cloudflare
609
+ // challenges that hang on "load" (rare) or other failures silently
610
+ // swallow the bot-detection signal.
611
+ let pageBotDetected = false;
612
+ try {
613
+ const currentPage = this.getPage(taskId);
614
+ if (currentPage) {
615
+ pageBotDetected = await this.checkBotDetection(currentPage);
616
+ }
617
+ } catch {
618
+ // page may not exist
619
+ }
620
+
621
+ const botDetected =
622
+ pageBotDetected ||
623
+ msg.includes("captcha") ||
624
+ msg.includes("cloudflare") ||
625
+ msg.includes("blocked") ||
626
+ msg.includes("challenge");
627
+
628
+ this._log("navigate", {
629
+ url,
630
+ plugin: this.name,
631
+ success: false,
632
+ botDetected: botDetected ?? false,
633
+ elementCount: 0,
634
+ error: msg,
635
+ time: Math.round(performance.now() - _start),
636
+ });
637
+
638
+ return {
639
+ success: false,
640
+ url,
641
+ title: "",
642
+ snapshot: "",
643
+ elementCount: 0,
644
+ botDetected,
645
+ error: msg,
646
+ };
647
+ }
648
+ }
649
+
650
+ async snapshot(taskId: string): Promise<SnapshotResult> {
651
+ const _start = performance.now();
652
+ const page = this.requirePage(taskId, "snapshot");
653
+ if (!page) {
654
+ return {
655
+ success: false,
656
+ snapshot: "",
657
+ elementCount: 0,
658
+ error: "No active session",
659
+ };
660
+ }
661
+
662
+ try {
663
+ const {
664
+ snapshot: snapText,
665
+ elementCount,
666
+ dialogEvents,
667
+ } = await this.takeSnapshot(taskId, page);
668
+
669
+ this._log("snapshot", {
670
+ taskId,
671
+ success: true,
672
+ elementCount,
673
+ dialogEvents: dialogEvents.length,
674
+ fingerprint: snapText.slice(0, 16),
675
+ time: Math.round(performance.now() - _start),
676
+ });
677
+
678
+ return {
679
+ success: true,
680
+ snapshot: snapText,
681
+ elementCount,
682
+ dialogEvents,
683
+ };
684
+ } catch (err: unknown) {
685
+ this._log("snapshot", {
686
+ taskId,
687
+ success: false,
688
+ elementCount: 0,
689
+ error: err instanceof Error ? err.message : String(err),
690
+ time: Math.round(performance.now() - _start),
691
+ });
692
+
693
+ return {
694
+ success: false,
695
+ snapshot: "",
696
+ elementCount: 0,
697
+ error: err instanceof Error ? err.message : String(err),
698
+ };
699
+ }
700
+ }
701
+
702
+ // ── Interaction ────────────────────────────────────────────
703
+
704
+ async click(taskId: string, ref: string): Promise<InteractionResult> {
705
+ const _start = performance.now();
706
+ const phases: Record<string, number> = {};
707
+ const page = this.requirePage(taskId, "click");
708
+ if (!page) {
709
+ return { success: false, error: "No active session" };
710
+ }
711
+
712
+ const key = ref.startsWith("@") ? ref.slice(1) : ref;
713
+ const node = this.getOrCreateCache(taskId).get(key);
714
+
715
+ if (!node) {
716
+ this._log("click", {
717
+ taskId,
718
+ ref,
719
+ role: "(none)",
720
+ name: "(none)",
721
+ result: "fail",
722
+ error: `Element ${ref} not found in accessibility tree`,
723
+ time: Math.round(performance.now() - _start),
724
+ });
725
+ return {
726
+ success: false,
727
+ error: `Element ${ref} not found in accessibility tree. Refresh with browser-snapshot first.`,
728
+ };
729
+ }
730
+
731
+ const locator = buildLocator(page, node);
732
+ if (!locator) {
733
+ this._log("click", {
734
+ taskId,
735
+ ref,
736
+ role: node.role,
737
+ name: node.name,
738
+ result: "fail",
739
+ error: `Could not build locator (role: ${node.role})`,
740
+ time: Math.round(performance.now() - _start),
741
+ });
742
+ return {
743
+ success: false,
744
+ error: `Could not build locator for ${ref} (role: ${node.role})`,
745
+ };
746
+ }
747
+ phases.locate = Math.round(performance.now() - _start);
748
+
749
+ try {
750
+ const urlBefore = page.url();
751
+ await locator.click({ timeout: 5000 });
752
+ phases.click = Math.round(performance.now() - _start);
753
+
754
+ // Wait for potential navigation to settle (replaces fixed sleep)
755
+ const { navigated } = await waitForNavigationSettle(page, urlBefore);
756
+ phases.wait = Math.round(performance.now() - _start);
757
+
758
+ const newUrl = page.url();
759
+ const newTitle = await page.title();
760
+ sessionManager.updateSession(taskId, {
761
+ currentUrl: newUrl,
762
+ currentTitle: newTitle,
763
+ });
764
+
765
+ // Auto-snapshot
766
+ const snapResult = await this.takeSnapshot(taskId, page);
767
+ phases.snapshot = Math.round(performance.now() - _start);
768
+
769
+ this._log("click", {
770
+ taskId,
771
+ ref,
772
+ role: node.role,
773
+ name: node.name,
774
+ result: "success",
775
+ navigated,
776
+ timings: phases,
777
+ time: Math.round(performance.now() - _start),
778
+ });
779
+
780
+ return {
781
+ success: true,
782
+ newUrl,
783
+ newTitle,
784
+ snapshot: snapResult.snapshot,
785
+ elementCount: snapResult.elementCount,
786
+ dialogEvents: snapResult.dialogEvents,
787
+ };
788
+ } catch (err: unknown) {
789
+ this._log("click", {
790
+ taskId,
791
+ ref,
792
+ role: node.role,
793
+ name: node.name,
794
+ result: "fail",
795
+ error: err instanceof Error ? err.message : String(err),
796
+ timings: phases,
797
+ time: Math.round(performance.now() - _start),
798
+ });
799
+
800
+ return {
801
+ success: false,
802
+ error: `Click failed: ${err instanceof Error ? err.message : String(err)}`,
803
+ };
804
+ }
805
+ }
806
+
807
+ async type(
808
+ taskId: string,
809
+ ref: string,
810
+ text: string,
811
+ ): Promise<InteractionResult> {
812
+ const _start = performance.now();
813
+ const page = this.requirePage(taskId, "type");
814
+ if (!page) {
815
+ return { success: false, error: "No active session" };
816
+ }
817
+
818
+ const key = ref.startsWith("@") ? ref.slice(1) : ref;
819
+ const node = this.getOrCreateCache(taskId).get(key);
820
+
821
+ if (!node) {
822
+ this._log("type", {
823
+ taskId,
824
+ ref,
825
+ role: "(none)",
826
+ name: "(none)",
827
+ result: "fail",
828
+ error: `Element ${ref} not found in accessibility tree`,
829
+ time: Math.round(performance.now() - _start),
830
+ });
831
+ return {
832
+ success: false,
833
+ error: `Element ${ref} not found in accessibility tree. Refresh with browser-snapshot first.`,
834
+ };
835
+ }
836
+
837
+ const locator = buildLocator(page, node);
838
+ if (!locator) {
839
+ this._log("type", {
840
+ taskId,
841
+ ref,
842
+ role: node.role,
843
+ name: node.name,
844
+ result: "fail",
845
+ error: `Could not build locator (role: ${node.role})`,
846
+ time: Math.round(performance.now() - _start),
847
+ });
848
+ return {
849
+ success: false,
850
+ error: `Could not build locator for ${ref}`,
851
+ };
852
+ }
853
+
854
+ try {
855
+ await locator.click({ timeout: 5000 }); // Focus first
856
+ await locator.fill(text);
857
+
858
+ // Auto-snapshot
859
+ const snapResult = await this.takeSnapshot(taskId, page);
860
+
861
+ this._log("type", {
862
+ taskId,
863
+ ref,
864
+ role: node.role,
865
+ name: node.name,
866
+ result: "success",
867
+ elementCount: snapResult.elementCount,
868
+ time: Math.round(performance.now() - _start),
869
+ });
870
+
871
+ return {
872
+ success: true,
873
+ snapshot: snapResult.snapshot,
874
+ elementCount: snapResult.elementCount,
875
+ dialogEvents: snapResult.dialogEvents,
876
+ };
877
+ } catch (err: unknown) {
878
+ this._log("type", {
879
+ taskId,
880
+ ref,
881
+ role: node.role,
882
+ name: node.name,
883
+ result: "fail",
884
+ error: err instanceof Error ? err.message : String(err),
885
+ time: Math.round(performance.now() - _start),
886
+ });
887
+ return {
888
+ success: false,
889
+ error: `Type failed: ${err instanceof Error ? err.message : String(err)}`,
890
+ };
891
+ }
892
+ }
893
+
894
+ async scroll(
895
+ taskId: string,
896
+ direction: "up" | "down",
897
+ ): Promise<InteractionResult> {
898
+ const _start = performance.now();
899
+ const page = this.requirePage(taskId, "scroll");
900
+ if (!page) {
901
+ return { success: false, error: "No active session" };
902
+ }
903
+
904
+ try {
905
+ const delta = direction === "down" ? 800 : -800;
906
+ await page.evaluate((d: number) => {
907
+ window.scrollBy({ top: d, behavior: "smooth" });
908
+ }, delta);
909
+ await page.waitForTimeout(200);
910
+
911
+ const snapResult = await this.takeSnapshot(taskId, page);
912
+
913
+ this._log("scroll", {
914
+ taskId,
915
+ direction,
916
+ success: true,
917
+ elementCount: snapResult.elementCount,
918
+ time: Math.round(performance.now() - _start),
919
+ });
920
+
921
+ return {
922
+ success: true,
923
+ snapshot: snapResult.snapshot,
924
+ elementCount: snapResult.elementCount,
925
+ dialogEvents: snapResult.dialogEvents,
926
+ };
927
+ } catch (err: unknown) {
928
+ this._log("scroll", {
929
+ taskId,
930
+ direction,
931
+ success: false,
932
+ error: err instanceof Error ? err.message : String(err),
933
+ time: Math.round(performance.now() - _start),
934
+ });
935
+ return {
936
+ success: false,
937
+ error: `Scroll failed: ${err instanceof Error ? err.message : String(err)}`,
938
+ };
939
+ }
940
+ }
941
+
942
+ async goBack(taskId: string): Promise<InteractionResult> {
943
+ const _start = performance.now();
944
+ const page = this.requirePage(taskId, "goBack");
945
+ if (!page) {
946
+ return { success: false, error: "No active session" };
947
+ }
948
+
949
+ try {
950
+ await page.goBack({ waitUntil: "networkidle" });
951
+ await page.waitForTimeout(300);
952
+
953
+ const newUrl = page.url();
954
+ const newTitle = await page.title();
955
+ sessionManager.updateSession(taskId, {
956
+ currentUrl: newUrl,
957
+ currentTitle: newTitle,
958
+ });
959
+
960
+ const snapResult = await this.takeSnapshot(taskId, page);
961
+
962
+ this._log("goBack", {
963
+ taskId,
964
+ success: true,
965
+ elementCount: snapResult.elementCount,
966
+ time: Math.round(performance.now() - _start),
967
+ });
968
+
969
+ return {
970
+ success: true,
971
+ newUrl,
972
+ newTitle,
973
+ snapshot: snapResult.snapshot,
974
+ elementCount: snapResult.elementCount,
975
+ dialogEvents: snapResult.dialogEvents,
976
+ };
977
+ } catch (err: unknown) {
978
+ this._log("goBack", {
979
+ taskId,
980
+ success: false,
981
+ error: err instanceof Error ? err.message : String(err),
982
+ time: Math.round(performance.now() - _start),
983
+ });
984
+ return {
985
+ success: false,
986
+ error: `GoBack failed: ${err instanceof Error ? err.message : String(err)}`,
987
+ };
988
+ }
989
+ }
990
+
991
+ async press(taskId: string, key: string): Promise<InteractionResult> {
992
+ const _start = performance.now();
993
+ const page = this.requirePage(taskId, "press");
994
+ if (!page) {
995
+ return { success: false, error: "No active session" };
996
+ }
997
+
998
+ try {
999
+ const urlBefore = page.url();
1000
+ await page.keyboard.press(key);
1001
+
1002
+ // Wait for potential navigation to settle (replaces fixed sleep).
1003
+ // Shorter nav timeout since Enter-on-link nav is typically fast.
1004
+ const { navigated } = await waitForNavigationSettle(page, urlBefore, {
1005
+ navTimeoutMs: 3000,
1006
+ });
1007
+
1008
+ const newUrl = page.url();
1009
+ const newTitle = await page.title();
1010
+ sessionManager.updateSession(taskId, {
1011
+ currentUrl: newUrl,
1012
+ currentTitle: newTitle,
1013
+ });
1014
+
1015
+ const snapResult = await this.takeSnapshot(taskId, page);
1016
+
1017
+ this._log("press", {
1018
+ taskId,
1019
+ key,
1020
+ success: true,
1021
+ navigated,
1022
+ elementCount: snapResult.elementCount,
1023
+ time: Math.round(performance.now() - _start),
1024
+ });
1025
+
1026
+ return {
1027
+ success: true,
1028
+ newUrl,
1029
+ newTitle,
1030
+ snapshot: snapResult.snapshot,
1031
+ elementCount: snapResult.elementCount,
1032
+ dialogEvents: snapResult.dialogEvents,
1033
+ };
1034
+ } catch (err: unknown) {
1035
+ this._log("press", {
1036
+ taskId,
1037
+ key,
1038
+ success: false,
1039
+ error: err instanceof Error ? err.message : String(err),
1040
+ time: Math.round(performance.now() - _start),
1041
+ });
1042
+ return {
1043
+ success: false,
1044
+ error: `Press failed: ${err instanceof Error ? err.message : String(err)}`,
1045
+ };
1046
+ }
1047
+ }
1048
+
1049
+ // ── Media ──────────────────────────────────────────────────
1050
+
1051
+ async screenshot(
1052
+ taskId: string,
1053
+ options?: { fullPage?: boolean },
1054
+ ): Promise<ScreenshotResult> {
1055
+ const page = this.requirePage(taskId);
1056
+ if (!page) {
1057
+ return { success: false, dataUri: "", error: "No active session" };
1058
+ }
1059
+
1060
+ try {
1061
+ const buffer = await page.screenshot({
1062
+ type: "jpeg",
1063
+ quality: 80,
1064
+ fullPage: options?.fullPage ?? false,
1065
+ });
1066
+ const base64 = buffer.toString("base64");
1067
+ const dataUri = `data:image/jpeg;base64,${base64}`;
1068
+
1069
+ return { success: true, dataUri };
1070
+ } catch (err: unknown) {
1071
+ return {
1072
+ success: false,
1073
+ dataUri: "",
1074
+ error: err instanceof Error ? err.message : String(err),
1075
+ };
1076
+ }
1077
+ }
1078
+
1079
+ // ── Console & eval ─────────────────────────────────────────
1080
+
1081
+ async getConsoleMessages(taskId: string): Promise<ConsoleMessagesResult> {
1082
+ const raw = getRawConsoleLog(taskId);
1083
+ return {
1084
+ success: true,
1085
+ messages: raw.map((c) => ({ type: c.type, text: c.text })),
1086
+ };
1087
+ }
1088
+
1089
+ async clearConsole(taskId: string): Promise<void> {
1090
+ clearConsoleLog(taskId);
1091
+ }
1092
+
1093
+ async evaluate(taskId: string, expression: string): Promise<EvaluateResult> {
1094
+ const page = this.requirePage(taskId);
1095
+ if (!page) {
1096
+ return { success: false, error: "No active session" };
1097
+ }
1098
+
1099
+ try {
1100
+ const result = await page.evaluate(expression);
1101
+ return { success: true, result };
1102
+ } catch (err: unknown) {
1103
+ return {
1104
+ success: false,
1105
+ error: err instanceof Error ? err.message : String(err),
1106
+ };
1107
+ }
1108
+ }
1109
+
1110
+ // ── Cookies & storage state ───────────────────────────────
1111
+
1112
+ async getCookies(taskId: string, urls?: string[]): Promise<CookieResult> {
1113
+ const _start = performance.now();
1114
+ const entry = this.requireEntry(taskId, "getCookies");
1115
+ if (!entry) {
1116
+ return { success: false, cookies: [], error: "No active session" };
1117
+ }
1118
+
1119
+ try {
1120
+ const cookies = await entry.context.cookies(urls);
1121
+ this._log("getCookies", {
1122
+ taskId,
1123
+ success: true,
1124
+ count: cookies.length,
1125
+ time: Math.round(performance.now() - _start),
1126
+ });
1127
+ return { success: true, cookies };
1128
+ } catch (err: unknown) {
1129
+ this._log("getCookies", {
1130
+ taskId,
1131
+ success: false,
1132
+ error: err instanceof Error ? err.message : String(err),
1133
+ time: Math.round(performance.now() - _start),
1134
+ });
1135
+ return {
1136
+ success: false,
1137
+ cookies: [],
1138
+ error: err instanceof Error ? err.message : String(err),
1139
+ };
1140
+ }
1141
+ }
1142
+
1143
+ async addCookies(taskId: string, cookies: Cookie[]): Promise<ResultBase> {
1144
+ const _start = performance.now();
1145
+ const entry = this.requireEntry(taskId, "addCookies");
1146
+ if (!entry) {
1147
+ return { success: false, error: "No active session" };
1148
+ }
1149
+
1150
+ try {
1151
+ await entry.context.addCookies(cookies);
1152
+ this._log("addCookies", {
1153
+ taskId,
1154
+ success: true,
1155
+ count: cookies.length,
1156
+ time: Math.round(performance.now() - _start),
1157
+ });
1158
+ return { success: true };
1159
+ } catch (err: unknown) {
1160
+ this._log("addCookies", {
1161
+ taskId,
1162
+ success: false,
1163
+ error: err instanceof Error ? err.message : String(err),
1164
+ time: Math.round(performance.now() - _start),
1165
+ });
1166
+ return {
1167
+ success: false,
1168
+ error: err instanceof Error ? err.message : String(err),
1169
+ };
1170
+ }
1171
+ }
1172
+
1173
+ async clearCookies(
1174
+ taskId: string,
1175
+ options?: ClearCookiesOptions,
1176
+ ): Promise<ResultBase> {
1177
+ const _start = performance.now();
1178
+ const entry = this.requireEntry(taskId, "clearCookies");
1179
+ if (!entry) {
1180
+ return { success: false, error: "No active session" };
1181
+ }
1182
+
1183
+ try {
1184
+ await entry.context.clearCookies({
1185
+ ...(options?.name ? { name: options.name } : {}),
1186
+ ...(options?.domain ? { domain: options.domain } : {}),
1187
+ ...(options?.path ? { path: options.path } : {}),
1188
+ });
1189
+ this._log("clearCookies", {
1190
+ taskId,
1191
+ success: true,
1192
+ time: Math.round(performance.now() - _start),
1193
+ });
1194
+ return { success: true };
1195
+ } catch (err: unknown) {
1196
+ this._log("clearCookies", {
1197
+ taskId,
1198
+ success: false,
1199
+ error: err instanceof Error ? err.message : String(err),
1200
+ time: Math.round(performance.now() - _start),
1201
+ });
1202
+ return {
1203
+ success: false,
1204
+ error: err instanceof Error ? err.message : String(err),
1205
+ };
1206
+ }
1207
+ }
1208
+
1209
+ async getStorageState(taskId: string): Promise<StorageStateResult> {
1210
+ const _start = performance.now();
1211
+ const entry = this.requireEntry(taskId, "getStorageState");
1212
+ if (!entry) {
1213
+ return {
1214
+ success: false,
1215
+ cookies: [],
1216
+ origins: [],
1217
+ error: "No active session",
1218
+ };
1219
+ }
1220
+
1221
+ try {
1222
+ const state = await entry.context.storageState();
1223
+ this._log("getStorageState", {
1224
+ taskId,
1225
+ success: true,
1226
+ cookies: state.cookies.length,
1227
+ origins: state.origins.length,
1228
+ time: Math.round(performance.now() - _start),
1229
+ });
1230
+ return {
1231
+ success: true,
1232
+ cookies: state.cookies,
1233
+ origins: state.origins,
1234
+ };
1235
+ } catch (err: unknown) {
1236
+ this._log("getStorageState", {
1237
+ taskId,
1238
+ success: false,
1239
+ error: err instanceof Error ? err.message : String(err),
1240
+ time: Math.round(performance.now() - _start),
1241
+ });
1242
+ return {
1243
+ success: false,
1244
+ cookies: [],
1245
+ origins: [],
1246
+ error: err instanceof Error ? err.message : String(err),
1247
+ };
1248
+ }
1249
+ }
1250
+
1251
+ // ── Per-task cleanup ───────────────────────────────────────
1252
+
1253
+ async cleanup(taskId: string): Promise<void> {
1254
+ const entry = this._pages.get(taskId);
1255
+ if (!entry) return;
1256
+
1257
+ const { context, page } = entry;
1258
+
1259
+ // ── Auto-save storage state for persistent profiles ──────────
1260
+ await this._persistState(taskId, context).catch(() => {});
1261
+
1262
+ // ── Tracing: stop before closing (if enabled) ────────────────
1263
+ const traceDir = process.env.BROWSER_TRACE_DIR;
1264
+ if (traceDir) {
1265
+ try {
1266
+ mkdirSync(traceDir, { recursive: true });
1267
+ await context.tracing.stop({
1268
+ path: join(traceDir, `trace-${taskId}-${Date.now()}.zip`),
1269
+ });
1270
+ this._log("tracing", {
1271
+ taskId,
1272
+ action: "stop",
1273
+ dir: traceDir,
1274
+ });
1275
+ } catch {
1276
+ // Best-effort — trace is diagnostic only
1277
+ }
1278
+ }
1279
+
1280
+ // ── Close page + context (always — no ref-counting) ──────────
1281
+ try {
1282
+ await page.close();
1283
+ } catch {
1284
+ /* page may already be closed */
1285
+ }
1286
+ try {
1287
+ await context.close();
1288
+ } catch {
1289
+ /* context may already be closed */
1290
+ }
1291
+ this._pages.delete(taskId);
1292
+ this._elementCache.delete(taskId);
1293
+ }
1294
+ }