teamshare-bridge 0.26.12 → 0.26.14

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 (35) hide show
  1. package/dist/cli/commands/research.js +15 -0
  2. package/dist/cli/commands/research.js.map +1 -1
  3. package/dist/lib/browser/batch.d.ts +62 -2
  4. package/dist/lib/browser/batch.js +90 -5
  5. package/dist/lib/browser/batch.js.map +1 -1
  6. package/dist/lib/browser/browse-cdp.js +5 -1
  7. package/dist/lib/browser/browse-cdp.js.map +1 -1
  8. package/dist/lib/browser/browse-editor.js +8 -0
  9. package/dist/lib/browser/browse-editor.js.map +1 -1
  10. package/dist/lib/browser/cdp.d.ts +11 -0
  11. package/dist/lib/browser/cdp.js +36 -0
  12. package/dist/lib/browser/cdp.js.map +1 -1
  13. package/dist/lib/browser/index.d.ts +3 -1
  14. package/dist/lib/browser/index.js +12 -1
  15. package/dist/lib/browser/index.js.map +1 -1
  16. package/dist/lib/browser/login.d.ts +67 -25
  17. package/dist/lib/browser/login.js +681 -174
  18. package/dist/lib/browser/login.js.map +1 -1
  19. package/dist/lib/browser/network-log.d.ts +40 -0
  20. package/dist/lib/browser/network-log.js +115 -12
  21. package/dist/lib/browser/network-log.js.map +1 -1
  22. package/dist/lib/browser/page-context.d.ts +198 -0
  23. package/dist/lib/browser/page-context.js +804 -0
  24. package/dist/lib/browser/page-context.js.map +1 -0
  25. package/dist/lib/browser/page-events.d.ts +39 -0
  26. package/dist/lib/browser/page-events.js +128 -0
  27. package/dist/lib/browser/page-events.js.map +1 -0
  28. package/dist/lib/browser/screen-change.js +3 -0
  29. package/dist/lib/browser/screen-change.js.map +1 -1
  30. package/dist/lib/browser/session.d.ts +159 -3
  31. package/dist/lib/browser/session.js +596 -7
  32. package/dist/lib/browser/session.js.map +1 -1
  33. package/dist/lib/orchestrator/handlers/browser.js +300 -31
  34. package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
  35. package/package.json +1 -1
@@ -55,6 +55,9 @@ const launch_1 = require("./launch");
55
55
  const owned_registry_1 = require("./owned-registry");
56
56
  const process_1 = require("./process");
57
57
  const login_1 = require("./login");
58
+ const page_context_1 = require("./page-context");
59
+ const page_events_1 = require("./page-events");
60
+ const screen_change_1 = require("./screen-change");
58
61
  /**
59
62
  * Persistent Chrome profile for the AUTO-ANSWER browser (research reads,
60
63
  * `browse --auto-answer`, the web-auto-answer tool).
@@ -78,6 +81,12 @@ exports.AUTO_ANSWER_CDP_PORT = 9222;
78
81
  exports.DEBUG_CDP_PORT = 9333;
79
82
  /** Separate user-data-dir for the debug browser (see DEBUG_CDP_PORT). */
80
83
  exports.DEBUG_PROFILE_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.teamshare', 'chrome-profile-debug');
84
+ /**
85
+ * Pages that already carry the debug instrumentation (dialog listener,
86
+ * frame-navigation re-inject). A WeakSet, because a reconnect creates a new
87
+ * CdpPage and must instrument it again.
88
+ */
89
+ const instrumentedPages = new WeakSet();
81
90
  class BrowserSession {
82
91
  id;
83
92
  page = null;
@@ -102,6 +111,13 @@ class BrowserSession {
102
111
  * { action: "networkLog" }` when it needs to prove an API mismatch.
103
112
  */
104
113
  network = new network_log_1.NetworkLog();
114
+ /**
115
+ * The native alert/confirm/prompt/beforeunload currently blocking the page
116
+ * (2026-10). Captured from `Page.javascriptDialogOpening`; other actions are
117
+ * refused while it is set, and `browser_action({action:"dialog"})` resolves
118
+ * it. A native dialog blocks the renderer, so a page snapshot would hang.
119
+ */
120
+ _nativeDialog = null;
105
121
  constructor(id, opts) {
106
122
  this.id = id;
107
123
  this._port = opts?.port ?? exports.DEBUG_CDP_PORT;
@@ -155,6 +171,10 @@ class BrowserSession {
155
171
  get allowCredentials() {
156
172
  return this._allowCredentials;
157
173
  }
174
+ /** The native JS dialog currently blocking the page, if any. */
175
+ get nativeDialog() {
176
+ return this._nativeDialog;
177
+ }
158
178
  /** Last time a command actually used this session (drives idle close). */
159
179
  _lastUsedAt = Date.now();
160
180
  /** When this session was last used — the idle-close timer reads this. */
@@ -199,6 +219,7 @@ class BrowserSession {
199
219
  async connect(port) {
200
220
  if (port)
201
221
  this._port = port;
222
+ this._nativeDialog = null;
202
223
  this.touch();
203
224
  // Try an existing Chrome first (the human may have started a debug Chrome
204
225
  // and signed in on it — reusing it keeps their session and cookies).
@@ -242,6 +263,9 @@ class BrowserSession {
242
263
  }
243
264
  // Capture the network + console stream for the whole session (QA evidence).
244
265
  await this.network.attach(this.page);
266
+ // Rich page context: persistent toast/dialog buffer, screen-change
267
+ // observer and native-dialog capture (2026-10).
268
+ await this.instrumentPage(this.page);
245
269
  return (0, cdp_1.getPageInfo)(this.page);
246
270
  }
247
271
  /**
@@ -265,11 +289,389 @@ class BrowserSession {
265
289
  /* best-effort */
266
290
  }
267
291
  await this.network.attach(this.page);
292
+ await this.instrumentPage(this.page);
268
293
  return (0, cdp_1.getPageInfo)(this.page);
269
294
  }
270
295
  /**
271
- * Navigate to a URL. Returns page info after navigation.
296
+ * Install the debug browser's page instrumentation (2026-10):
297
+ *
298
+ * - the persistent toast/alert/dialog ring buffer (`page-events.ts`),
299
+ * registered for FUTURE documents via
300
+ * `Page.addScriptToEvaluateOnNewDocument` and injected into the current
301
+ * one immediately;
302
+ * - the screen-change observer (`__tsScreenObs.mutated`) so `waitFor
303
+ * {for:"change"}` knows a click had an effect without a blind sleep;
304
+ * - the native-dialog listener (`Page.javascriptDialogOpening`), because an
305
+ * alert/confirm/prompt blocks the renderer and must be surfaced and
306
+ * resolved explicitly.
307
+ *
308
+ * All pieces are idempotent in the page. The CDP listeners are bound once per
309
+ * page object (a reconnect creates a new one).
272
310
  */
311
+ async instrumentPage(page) {
312
+ try {
313
+ await (0, cdp_1.cdpSend)(page, 'Page.enable', {});
314
+ }
315
+ catch {
316
+ /* already enabled / restricted */
317
+ }
318
+ try {
319
+ await (0, cdp_1.cdpSend)(page, 'Page.addScriptToEvaluateOnNewDocument', {
320
+ source: (0, page_events_1.buildPageEventScript)(),
321
+ });
322
+ }
323
+ catch {
324
+ /* older Chrome may refuse; the immediate injection still runs */
325
+ }
326
+ const install = async () => {
327
+ try {
328
+ await (0, cdp_1.evaluateJs)(page, (0, page_events_1.buildPageEventScript)());
329
+ }
330
+ catch {
331
+ /* document not ready yet; the new-document script covers it */
332
+ }
333
+ try {
334
+ await (0, cdp_1.evaluateJs)(page, (0, screen_change_1.buildObserverScript)());
335
+ }
336
+ catch {
337
+ /* best-effort */
338
+ }
339
+ };
340
+ await install();
341
+ if (instrumentedPages.has(page))
342
+ return;
343
+ instrumentedPages.add(page);
344
+ (0, cdp_1.onCdpEvent)(page, 'Page.javascriptDialogOpening', (params) => {
345
+ this._nativeDialog = {
346
+ type: String(params?.type ?? 'dialog'),
347
+ message: String(params?.message ?? ''),
348
+ ...(typeof params?.defaultPrompt === 'string'
349
+ ? { defaultPrompt: params.defaultPrompt }
350
+ : {}),
351
+ at: Date.now(),
352
+ };
353
+ });
354
+ (0, cdp_1.onCdpEvent)(page, 'Page.javascriptDialogClosed', () => {
355
+ this._nativeDialog = null;
356
+ });
357
+ // `Page.addScript...` already covers a real navigation; this is the belt
358
+ // and braces path for a document that was already loading when we attached.
359
+ (0, cdp_1.onCdpEvent)(page, 'Page.frameNavigated', (params) => {
360
+ if (!(0, screen_change_1.isMainFrameNavigation)(params))
361
+ return;
362
+ setTimeout(() => {
363
+ void install();
364
+ }, 600);
365
+ });
366
+ }
367
+ /**
368
+ * Resolve a native JS dialog (alert/confirm/prompt/beforeunload).
369
+ * `accept:false` dismisses it; `promptText` is used by a prompt.
370
+ */
371
+ async dialogAction(opts) {
372
+ this.ensureConnected();
373
+ const current = this._nativeDialog;
374
+ if (!current) {
375
+ return { ok: false, error: 'No native browser dialog is open.' };
376
+ }
377
+ try {
378
+ await (0, cdp_1.cdpSend)(this.page, 'Page.handleJavaScriptDialog', {
379
+ accept: opts.accept !== false,
380
+ ...(typeof opts.promptText === 'string'
381
+ ? { promptText: opts.promptText }
382
+ : {}),
383
+ });
384
+ this._nativeDialog = null;
385
+ return {
386
+ ok: true,
387
+ accepted: opts.accept !== false,
388
+ type: current.type,
389
+ message: current.message,
390
+ };
391
+ }
392
+ catch (err) {
393
+ return {
394
+ ok: false,
395
+ error: err instanceof Error ? err.message : String(err),
396
+ };
397
+ }
398
+ }
399
+ /**
400
+ * Current pagination state (controls, page, page size, total, rows).
401
+ * Scoped to the top dialog by the page-state script.
402
+ */
403
+ async pagination() {
404
+ this.ensureConnected();
405
+ const state = await (0, page_context_1.readPageState)(this.page, { includeText: false });
406
+ return state.pagination;
407
+ }
408
+ /** Stable-ish signature used to prove a page actually changed. */
409
+ async paginationSignature() {
410
+ const state = await (0, page_context_1.readPageState)(this.page, { includeText: false });
411
+ let firstRow = '';
412
+ try {
413
+ firstRow = String(await (0, cdp_1.evaluateJs)(this.page, `(() => {
414
+ const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
415
+ const rows = [...document.querySelectorAll('tbody tr,[role="row"]')].filter(vis);
416
+ const row = rows.find((r) => r.querySelectorAll('td').length > 0);
417
+ return row ? String(row.innerText || row.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80) : '';
418
+ })()`));
419
+ }
420
+ catch {
421
+ /* navigation / context loss */
422
+ }
423
+ const p = state.pagination;
424
+ return [
425
+ p.page ?? '',
426
+ p.rowCount,
427
+ p.total ?? '',
428
+ firstRow,
429
+ p.controls.map((c) => `${c.kind}:${c.disabled ? 1 : 0}`).join(','),
430
+ ].join('|');
431
+ }
432
+ async waitPaginationChange(before, timeoutMs = 8000) {
433
+ const started = Date.now();
434
+ const deadline = started + Math.min(Math.max(timeoutMs, 500), 30_000);
435
+ while (Date.now() < deadline) {
436
+ await new Promise((resolve) => setTimeout(resolve, 220));
437
+ try {
438
+ if ((await this.paginationSignature()) !== before) {
439
+ return {
440
+ moved: true,
441
+ state: await this.pagination(),
442
+ elapsedMs: Date.now() - started,
443
+ };
444
+ }
445
+ }
446
+ catch {
447
+ /* navigation during the poll - retry */
448
+ }
449
+ }
450
+ return {
451
+ moved: false,
452
+ state: await this.pagination().catch(() => ({
453
+ kind: 'none',
454
+ hasNext: false,
455
+ hasPrev: false,
456
+ rowCount: 0,
457
+ pageSizeOptions: [],
458
+ controls: [],
459
+ })),
460
+ elapsedMs: Date.now() - started,
461
+ };
462
+ }
463
+ async clickPagination(kind, page) {
464
+ const state = await this.pagination();
465
+ const control = state.controls.find((c) => c.kind === kind && (page === undefined || c.page === page)) ??
466
+ (kind === 'next'
467
+ ? state.controls.find((c) => c.kind === 'loadMore')
468
+ : undefined);
469
+ if (!control || control.disabled)
470
+ return { clicked: false };
471
+ await (0, cdp_1.clickAt)(this.page, control.x, control.y);
472
+ return { clicked: true, control };
473
+ }
474
+ /** Move to the next page (or Load more) and wait for the content to change. */
475
+ async nextPage(timeoutMs = 8000) {
476
+ this.ensureConnected();
477
+ const before = await this.paginationSignature();
478
+ const clicked = await this.clickPagination('next');
479
+ if (!clicked.clicked) {
480
+ return {
481
+ moved: false,
482
+ elapsedMs: 0,
483
+ state: await this.pagination(),
484
+ reason: 'no enabled next/load-more control',
485
+ };
486
+ }
487
+ const result = await this.waitPaginationChange(before, timeoutMs);
488
+ return {
489
+ ...result,
490
+ ...(result.moved ? {} : { reason: 'page did not change' }),
491
+ };
492
+ }
493
+ /** Move to the previous page. */
494
+ async prevPage(timeoutMs = 8000) {
495
+ this.ensureConnected();
496
+ const before = await this.paginationSignature();
497
+ const clicked = await this.clickPagination('prev');
498
+ if (!clicked.clicked) {
499
+ return {
500
+ moved: false,
501
+ elapsedMs: 0,
502
+ state: await this.pagination(),
503
+ reason: 'no enabled previous control',
504
+ };
505
+ }
506
+ const result = await this.waitPaginationChange(before, timeoutMs);
507
+ return {
508
+ ...result,
509
+ ...(result.moved ? {} : { reason: 'page did not change' }),
510
+ };
511
+ }
512
+ /** Go to a numbered page; walks next if the page control is not rendered. */
513
+ async gotoPage(page, timeoutMs = 8000) {
514
+ this.ensureConnected();
515
+ const target = Math.max(1, Math.floor(page));
516
+ const current = await this.pagination();
517
+ if (current.page === target) {
518
+ return {
519
+ moved: false,
520
+ elapsedMs: 0,
521
+ state: current,
522
+ reason: 'already on page',
523
+ };
524
+ }
525
+ const before = await this.paginationSignature();
526
+ const direct = await this.clickPagination('page', target);
527
+ if (direct.clicked) {
528
+ const result = await this.waitPaginationChange(before, timeoutMs);
529
+ return {
530
+ ...result,
531
+ ...(result.moved ? {} : { reason: 'page did not change' }),
532
+ };
533
+ }
534
+ // Walk forward (bounded) when only prev/next controls exist.
535
+ for (let i = 0; i < Math.min(target, 100); i += 1) {
536
+ const next = await this.nextPage(timeoutMs);
537
+ if (!next.moved)
538
+ return { ...next, reason: next.reason ?? 'page did not change' };
539
+ if (next.state.page === target)
540
+ return next;
541
+ }
542
+ return {
543
+ moved: false,
544
+ elapsedMs: 0,
545
+ state: await this.pagination(),
546
+ reason: 'page control not found and the target was not reached',
547
+ };
548
+ }
549
+ /** Select a native page-size option (custom comboboxes use click + waitFor). */
550
+ async setPageSize(size) {
551
+ this.ensureConnected();
552
+ const before = await this.paginationSignature();
553
+ const changed = await (0, cdp_1.evaluateJs)(this.page, `(() => {
554
+ const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
555
+ const target = ${Math.max(1, Math.floor(size))};
556
+ for (const sel of [...document.querySelectorAll('select')].filter(vis)) {
557
+ const options = [...sel.options];
558
+ const option = options.find((o) => Number(String(o.textContent || o.label || '').trim()) === target);
559
+ if (!option) continue;
560
+ const proto = HTMLSelectElement.prototype;
561
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');
562
+ if (desc && desc.set) desc.set.call(sel, option.value);
563
+ else sel.value = option.value;
564
+ sel.dispatchEvent(new Event('input', { bubbles: true }));
565
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
566
+ return true;
567
+ }
568
+ return false;
569
+ })()`);
570
+ if (changed !== true) {
571
+ return {
572
+ ok: false,
573
+ state: await this.pagination(),
574
+ error: 'page size is not a native select; use getElements + click on the custom combobox',
575
+ };
576
+ }
577
+ const result = await this.waitPaginationChange(before);
578
+ return {
579
+ ok: true,
580
+ state: result.state,
581
+ ...(result.moved ? {} : { error: 'page did not visibly change' }),
582
+ };
583
+ }
584
+ /**
585
+ * Read the current table (headers + rows), then walk every page and return
586
+ * the deduplicated rows. Bounded by maxPages/maxRows; stops on a repeated
587
+ * page signature, a disabled pager, or an unchanged table.
588
+ */
589
+ async collectTable(opts) {
590
+ this.ensureConnected();
591
+ if (opts?.pageSize)
592
+ await this.setPageSize(opts.pageSize);
593
+ const maxPages = Math.min(Math.max(opts?.maxPages ?? 50, 1), 200);
594
+ const maxRows = Math.min(Math.max(opts?.maxRows ?? 2000, 1), 10_000);
595
+ const seen = new Set();
596
+ const rows = [];
597
+ let headers = [];
598
+ let duplicates = 0;
599
+ let pagesVisited = 0;
600
+ let stoppedBecause = 'unknown';
601
+ let truncated = false;
602
+ const readTable = async () => {
603
+ try {
604
+ const raw = await (0, cdp_1.evaluateJs)(this.page, `(() => {
605
+ const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
606
+ const selector = ${JSON.stringify(opts?.selector ?? '')};
607
+ let table = selector ? document.querySelector(selector) : null;
608
+ if (!table) {
609
+ const tables = [...document.querySelectorAll('table,[role="table"],[role="grid"]')].filter(vis);
610
+ table = tables.sort((a, b) => b.querySelectorAll('tr,[role="row"]').length - a.querySelectorAll('tr,[role="row"]').length)[0] || null;
611
+ }
612
+ if (!table) return null;
613
+ const headers = [...table.querySelectorAll('thead th,[role="columnheader"]')].map((h) => String(h.innerText || h.textContent || '').replace(/\\s+/g, ' ').trim()).filter(Boolean);
614
+ const bodyRows = [...table.querySelectorAll('tbody tr,[role="row"]')].filter(vis)
615
+ .filter((r) => r.querySelectorAll('td,[role="gridcell"],[role="cell"]').length > 0);
616
+ return {
617
+ headers,
618
+ rows: bodyRows.map((r) => [...r.querySelectorAll('td,[role="gridcell"],[role="cell"]')].map((c) => String(c.innerText || c.textContent || '').replace(/\\s+/g, ' ').trim())),
619
+ };
620
+ })()`);
621
+ if (!raw || typeof raw !== 'object')
622
+ return null;
623
+ return raw;
624
+ }
625
+ catch {
626
+ return null;
627
+ }
628
+ };
629
+ for (let page = 0; page < maxPages; page += 1) {
630
+ const table = await readTable();
631
+ if (!table) {
632
+ stoppedBecause = 'no table found';
633
+ break;
634
+ }
635
+ pagesVisited += 1;
636
+ if (table.headers.length)
637
+ headers = table.headers;
638
+ for (const row of table.rows) {
639
+ const key = row.join('\u0001');
640
+ if (seen.has(key)) {
641
+ duplicates += 1;
642
+ continue;
643
+ }
644
+ seen.add(key);
645
+ rows.push(row);
646
+ if (rows.length >= maxRows) {
647
+ truncated = true;
648
+ stoppedBecause = 'maxRows';
649
+ break;
650
+ }
651
+ }
652
+ if (truncated)
653
+ break;
654
+ const next = await this.nextPage();
655
+ if (!next.moved) {
656
+ stoppedBecause = next.reason ?? 'end of pages';
657
+ break;
658
+ }
659
+ }
660
+ const state = await this.pagination().catch(() => null);
661
+ return {
662
+ headers,
663
+ rows,
664
+ rowCount: rows.length,
665
+ pagesVisited,
666
+ ...(state?.total ? { total: state.total } : {}),
667
+ complete: !truncated &&
668
+ (stoppedBecause === 'end of pages' ||
669
+ stoppedBecause === 'no enabled next/load-more control'),
670
+ duplicates,
671
+ truncated,
672
+ stoppedBecause,
673
+ };
674
+ }
273
675
  async navigate(url) {
274
676
  this.ensureConnected();
275
677
  return (0, cdp_1.navigateTo)(this.page, url);
@@ -279,10 +681,7 @@ class BrowserSession {
279
681
  */
280
682
  async screenshot(format = 'jpeg', quality = 70) {
281
683
  this.ensureConnected();
282
- const data = await (0, cdp_1.captureScreenshot)(this.page, format, quality);
283
- if (!data)
284
- throw new Error('Screenshot failed');
285
- return data;
684
+ return (0, cdp_1.captureScreenshotChecked)(this.page, format, quality);
286
685
  }
287
686
  /**
288
687
  * Read the network + console evidence collected for this session (QA).
@@ -308,13 +707,13 @@ class BrowserSession {
308
707
  * credential for the run - the debug browser is the QA surface, the
309
708
  * auto-answer loop is a separate path that still never touches credentials.
310
709
  */
311
- async type(text) {
710
+ async type(text, opts) {
312
711
  this.ensureConnected();
313
712
  const credential = await (0, login_1.isCredentialFocused)(this.page);
314
713
  if (credential && !this._allowCredentials) {
315
714
  throw new Error('LOGIN_BLOCKED: refusing to type into a password/credential field. Pass allowCredentials:true on browser_open (debug browser only), use browser_action({action:"login"}) with supplied credentials, or ask the human to sign in.');
316
715
  }
317
- await (0, login_1.typeTextVerified)(this.page, text);
716
+ await (0, login_1.typeTextVerified)(this.page, text, opts);
318
717
  }
319
718
  /**
320
719
  * Deterministically fill a login form (username + password) and submit.
@@ -619,6 +1018,195 @@ class BrowserSession {
619
1018
  this.ensureConnected();
620
1019
  return (0, cdp_1.getPageInfo)(this.page);
621
1020
  }
1021
+ /**
1022
+ * Full page state (2026-10): dialogs (topmost with buttons/fields), toasts
1023
+ * (persistent buffer), visible errors, form fields (lengths only), focus,
1024
+ * busy, headings, optional page text, pending requests and console errors.
1025
+ *
1026
+ * This is the debug browser's equivalent of the auto-answer loop's rich
1027
+ * `extractPageContext` - and it is what lets an agent see a login modal
1028
+ * instead of guessing with raw `evaluate`.
1029
+ */
1030
+ async snapshot(opts) {
1031
+ this.ensureConnected();
1032
+ // A native dialog blocks the renderer: an evaluate would hang. Report the
1033
+ // dialog plus the out-of-page evidence instead.
1034
+ if (this._nativeDialog) {
1035
+ return (0, page_context_1.emptyPageSnapshot)(this._nativeDialog, this.network.pendingCount(), this.network.recentErrors(5).map((c) => ({
1036
+ level: c.level,
1037
+ text: c.text,
1038
+ at: c.at,
1039
+ })));
1040
+ }
1041
+ const state = await (0, page_context_1.readPageState)(this.page, opts);
1042
+ return {
1043
+ ...state,
1044
+ nativeDialog: this._nativeDialog,
1045
+ pendingRequests: this.network.pendingCount(),
1046
+ consoleErrors: this.network.recentErrors(5).map((c) => ({
1047
+ level: c.level,
1048
+ text: c.text,
1049
+ at: c.at,
1050
+ })),
1051
+ };
1052
+ }
1053
+ /**
1054
+ * Bounded state attached to every mutating action result. Never include the
1055
+ * page text (that is what `snapshot` is for); a failure returns null so an
1056
+ * action result is never failed by its own observation.
1057
+ */
1058
+ async compactSnapshot() {
1059
+ // A native dialog blocks the renderer; return the dialog + out-of-page
1060
+ // evidence without an evaluate (which would hang).
1061
+ if (this._nativeDialog) {
1062
+ return {
1063
+ url: '',
1064
+ title: '',
1065
+ readyState: 'unknown',
1066
+ modal: false,
1067
+ dialogs: [],
1068
+ fields: [],
1069
+ focused: null,
1070
+ errors: [],
1071
+ toasts: [],
1072
+ busy: false,
1073
+ scroll: { x: 0, y: 0 },
1074
+ nativeDialog: this._nativeDialog,
1075
+ pendingRequests: this.network.pendingCount(),
1076
+ consoleErrors: this.network.recentErrors(3).map((c) => ({
1077
+ level: c.level,
1078
+ text: c.text,
1079
+ at: c.at,
1080
+ })),
1081
+ };
1082
+ }
1083
+ try {
1084
+ const state = await (0, page_context_1.readCompactState)(this.page);
1085
+ if (!state)
1086
+ return null;
1087
+ return {
1088
+ ...state,
1089
+ nativeDialog: this._nativeDialog,
1090
+ pendingRequests: this.network.pendingCount(),
1091
+ consoleErrors: this.network.recentErrors(3).map((c) => ({
1092
+ level: c.level,
1093
+ text: c.text,
1094
+ at: c.at,
1095
+ })),
1096
+ };
1097
+ }
1098
+ catch {
1099
+ return null;
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Recent toast / alert / dialog events from the persistent page buffer.
1104
+ * This is how a toast that disappeared before the next tool call is still
1105
+ * visible.
1106
+ */
1107
+ async events(opts) {
1108
+ this.ensureConnected();
1109
+ if (this._nativeDialog)
1110
+ return { events: [], count: 0 };
1111
+ const limit = Math.min(Math.max(opts?.limit ?? 50, 1), 200);
1112
+ const since = Math.max(0, Number(opts?.since) || 0);
1113
+ let events = [];
1114
+ try {
1115
+ const raw = await (0, cdp_1.evaluateJs)(this.page, `JSON.stringify((window.__tsPageEvents || []).filter((e) => e && e.at >= ${since}).slice(-${limit}))`);
1116
+ const parsed = JSON.parse(String(raw ?? '[]'));
1117
+ if (Array.isArray(parsed))
1118
+ events = parsed;
1119
+ }
1120
+ catch {
1121
+ /* navigation/context loss - return what we have (nothing) */
1122
+ }
1123
+ if (opts?.clear) {
1124
+ try {
1125
+ await (0, cdp_1.evaluateJs)(this.page, '(() => { window.__tsPageEvents = []; return true; })()');
1126
+ }
1127
+ catch {
1128
+ /* best-effort */
1129
+ }
1130
+ }
1131
+ return { events, count: events.length };
1132
+ }
1133
+ /**
1134
+ * Wait until a page condition is true, then return the current screen.
1135
+ *
1136
+ * This replaces the manual `evaluate` polling loop the QA agent used to run
1137
+ * (and which broke on "Inspected target navigated"). On timeout it returns
1138
+ * `matched:false` WITH the current screen, so the caller can see why.
1139
+ */
1140
+ async waitFor(opts) {
1141
+ this.ensureConnected();
1142
+ const started = Date.now();
1143
+ const timeout = Math.min(Math.max(Number(opts.timeoutMs) || 10_000, 250), 120_000);
1144
+ const pollMs = Math.min(Math.max(Number(opts.pollMs) || 250, 100), 2_000);
1145
+ const deadline = started + timeout;
1146
+ // Include a toast/mutation that landed just before the wait began.
1147
+ const since = started - 750;
1148
+ const probe = (0, page_context_1.buildWaitProbeScript)({ ...opts, since });
1149
+ let networkIdleTicks = 0;
1150
+ const matched = async () => ({
1151
+ matched: true,
1152
+ elapsedMs: Date.now() - started,
1153
+ for: opts.for,
1154
+ screen: await this.compactSnapshot(),
1155
+ nativeDialog: this._nativeDialog,
1156
+ });
1157
+ while (Date.now() < deadline) {
1158
+ if (this._nativeDialog) {
1159
+ if (opts.for === 'dialog' && opts.state !== 'closed') {
1160
+ return await matched();
1161
+ }
1162
+ // A native dialog blocks the page: any other condition cannot be
1163
+ // evaluated until the caller resolves it with `dialog`.
1164
+ }
1165
+ else if (opts.for === 'networkIdle') {
1166
+ if (this.network.pendingCount() === 0) {
1167
+ networkIdleTicks += 1;
1168
+ if (networkIdleTicks >= 2)
1169
+ return await matched();
1170
+ }
1171
+ else {
1172
+ networkIdleTicks = 0;
1173
+ }
1174
+ }
1175
+ else if (opts.for === 'loginStep') {
1176
+ try {
1177
+ const fields = await (0, login_1.findLoginFields)(this.page);
1178
+ const phase = (0, login_1.loginStepOf)(fields);
1179
+ if (!opts.loginPhase || phase === opts.loginPhase) {
1180
+ return await matched();
1181
+ }
1182
+ }
1183
+ catch {
1184
+ /* navigation - retry */
1185
+ }
1186
+ }
1187
+ else {
1188
+ try {
1189
+ const ok = await (0, cdp_1.evaluateJs)(this.page, probe);
1190
+ if (ok === true)
1191
+ return await matched();
1192
+ }
1193
+ catch {
1194
+ /* "Inspected target navigated" / execution context destroyed: retry */
1195
+ }
1196
+ }
1197
+ const remaining = deadline - Date.now();
1198
+ if (remaining <= 0)
1199
+ break;
1200
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
1201
+ }
1202
+ return {
1203
+ matched: false,
1204
+ elapsedMs: Date.now() - started,
1205
+ for: opts.for,
1206
+ screen: await this.compactSnapshot(),
1207
+ nativeDialog: this._nativeDialog,
1208
+ };
1209
+ }
622
1210
  /**
623
1211
  * Is the page actually usable right now? (2026-10)
624
1212
  *
@@ -674,6 +1262,7 @@ class BrowserSession {
674
1262
  this.page = null;
675
1263
  }
676
1264
  this._connected = false;
1265
+ this._nativeDialog = null;
677
1266
  }
678
1267
  /**
679
1268
  * Close the debug browser this session OWNS.