teamshare-bridge 0.26.13 → 0.26.15
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/dist/cli/commands/research.js +10 -2
- package/dist/cli/commands/research.js.map +1 -1
- package/dist/cli/commands/run.js +0 -21
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/lib/browser/batch.d.ts +77 -1
- package/dist/lib/browser/batch.js +151 -3
- package/dist/lib/browser/batch.js.map +1 -1
- package/dist/lib/browser/cdp.d.ts +61 -2
- package/dist/lib/browser/cdp.js +235 -14
- package/dist/lib/browser/cdp.js.map +1 -1
- package/dist/lib/browser/index.d.ts +2 -2
- package/dist/lib/browser/index.js +4 -1
- package/dist/lib/browser/index.js.map +1 -1
- package/dist/lib/browser/login.d.ts +20 -2
- package/dist/lib/browser/login.js +125 -88
- package/dist/lib/browser/login.js.map +1 -1
- package/dist/lib/browser/network-log.d.ts +31 -0
- package/dist/lib/browser/network-log.js +94 -12
- package/dist/lib/browser/network-log.js.map +1 -1
- package/dist/lib/browser/page-context.d.ts +62 -1
- package/dist/lib/browser/page-context.js +284 -1
- package/dist/lib/browser/page-context.js.map +1 -1
- package/dist/lib/browser/refs.d.ts +104 -0
- package/dist/lib/browser/refs.js +430 -0
- package/dist/lib/browser/refs.js.map +1 -0
- package/dist/lib/browser/session.d.ts +188 -7
- package/dist/lib/browser/session.js +537 -8
- package/dist/lib/browser/session.js.map +1 -1
- package/dist/lib/git/git-finalize.js +17 -2
- package/dist/lib/git/git-finalize.js.map +1 -1
- package/dist/lib/harness/harness-adapter.d.ts +2 -5
- package/dist/lib/harness/harness-adapter.js.map +1 -1
- package/dist/lib/harness/opencode-control.d.ts +73 -0
- package/dist/lib/harness/opencode-control.js +318 -0
- package/dist/lib/harness/opencode-control.js.map +1 -1
- package/dist/lib/harness/opencode-outcome.d.ts +16 -2
- package/dist/lib/harness/opencode-outcome.js +36 -7
- package/dist/lib/harness/opencode-outcome.js.map +1 -1
- package/dist/lib/harness/spawn.js +112 -48
- package/dist/lib/harness/spawn.js.map +1 -1
- package/dist/lib/orchestrator/handlers/browser.js +472 -27
- package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
- package/dist/lib/tools/browser-tool.js +24 -3
- package/dist/lib/tools/browser-tool.js.map +1 -1
- package/dist/lib/workspace/repo-lock.d.ts +5 -1
- package/dist/lib/workspace/repo-lock.js +5 -3
- package/dist/lib/workspace/repo-lock.js.map +1 -1
- package/package.json +1 -1
|
@@ -57,6 +57,7 @@ const process_1 = require("./process");
|
|
|
57
57
|
const login_1 = require("./login");
|
|
58
58
|
const page_context_1 = require("./page-context");
|
|
59
59
|
const page_events_1 = require("./page-events");
|
|
60
|
+
const refs_1 = require("./refs");
|
|
60
61
|
const screen_change_1 = require("./screen-change");
|
|
61
62
|
/**
|
|
62
63
|
* Persistent Chrome profile for the AUTO-ANSWER browser (research reads,
|
|
@@ -99,6 +100,14 @@ class BrowserSession {
|
|
|
99
100
|
_connected = false;
|
|
100
101
|
/** Opt-in credential entry (see BrowserSessionOptions.allowCredentials). */
|
|
101
102
|
_allowCredentials;
|
|
103
|
+
/** Opt-in QA vision mode (see BrowserSessionOptions.qaVision). */
|
|
104
|
+
_qaVision;
|
|
105
|
+
/**
|
|
106
|
+
* The last ref scan (2026-10). Kept so a stale ref can be explained and,
|
|
107
|
+
* when a unique same-role/same-name element exists in the fresh scan,
|
|
108
|
+
* transparently re-pointed - instead of failing with a mute miss.
|
|
109
|
+
*/
|
|
110
|
+
_lastRefs = [];
|
|
102
111
|
/**
|
|
103
112
|
* True when THIS bridge process launched the Chrome this session is attached
|
|
104
113
|
* to. Only such a browser may be terminated by the bridge; one the human
|
|
@@ -126,6 +135,7 @@ class BrowserSession {
|
|
|
126
135
|
this._profileDir = opts?.profileDir ?? exports.DEBUG_PROFILE_DIR;
|
|
127
136
|
this._chromeArgs = opts?.chromeArgs ?? [];
|
|
128
137
|
this._allowCredentials = opts?.allowCredentials ?? false;
|
|
138
|
+
this._qaVision = opts?.qaVision ?? false;
|
|
129
139
|
}
|
|
130
140
|
/** The stable session name the browser manager keys this session by. */
|
|
131
141
|
get sessionKey() {
|
|
@@ -160,6 +170,9 @@ class BrowserSession {
|
|
|
160
170
|
if (opts.allowCredentials !== undefined) {
|
|
161
171
|
this._allowCredentials = opts.allowCredentials;
|
|
162
172
|
}
|
|
173
|
+
if (opts.qaVision !== undefined) {
|
|
174
|
+
this._qaVision = opts.qaVision;
|
|
175
|
+
}
|
|
163
176
|
}
|
|
164
177
|
/**
|
|
165
178
|
* Toggle credential entry for this session. Persists across actions - a
|
|
@@ -171,6 +184,16 @@ class BrowserSession {
|
|
|
171
184
|
get allowCredentials() {
|
|
172
185
|
return this._allowCredentials;
|
|
173
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Toggle QA vision mode (2026-10). Persists across actions, exactly like
|
|
189
|
+
* allowCredentials: `browser_open({ qaVision: true })` sets it once.
|
|
190
|
+
*/
|
|
191
|
+
setQaVision(on) {
|
|
192
|
+
this._qaVision = on;
|
|
193
|
+
}
|
|
194
|
+
get qaVision() {
|
|
195
|
+
return this._qaVision;
|
|
196
|
+
}
|
|
174
197
|
/** The native JS dialog currently blocking the page, if any. */
|
|
175
198
|
get nativeDialog() {
|
|
176
199
|
return this._nativeDialog;
|
|
@@ -397,8 +420,281 @@ class BrowserSession {
|
|
|
397
420
|
}
|
|
398
421
|
}
|
|
399
422
|
/**
|
|
400
|
-
*
|
|
423
|
+
* Current pagination state (controls, page, page size, total, rows).
|
|
424
|
+
* Scoped to the top dialog by the page-state script.
|
|
401
425
|
*/
|
|
426
|
+
async pagination() {
|
|
427
|
+
this.ensureConnected();
|
|
428
|
+
const state = await (0, page_context_1.readPageState)(this.page, { includeText: false });
|
|
429
|
+
return state.pagination;
|
|
430
|
+
}
|
|
431
|
+
/** Stable-ish signature used to prove a page actually changed. */
|
|
432
|
+
async paginationSignature() {
|
|
433
|
+
const state = await (0, page_context_1.readPageState)(this.page, { includeText: false });
|
|
434
|
+
let firstRow = '';
|
|
435
|
+
try {
|
|
436
|
+
firstRow = String(await (0, cdp_1.evaluateJs)(this.page, `(() => {
|
|
437
|
+
const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
438
|
+
const rows = [...document.querySelectorAll('tbody tr,[role="row"]')].filter(vis);
|
|
439
|
+
const row = rows.find((r) => r.querySelectorAll('td').length > 0);
|
|
440
|
+
return row ? String(row.innerText || row.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80) : '';
|
|
441
|
+
})()`));
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
/* navigation / context loss */
|
|
445
|
+
}
|
|
446
|
+
const p = state.pagination;
|
|
447
|
+
return [
|
|
448
|
+
p.page ?? '',
|
|
449
|
+
p.rowCount,
|
|
450
|
+
p.total ?? '',
|
|
451
|
+
firstRow,
|
|
452
|
+
p.controls.map((c) => `${c.kind}:${c.disabled ? 1 : 0}`).join(','),
|
|
453
|
+
].join('|');
|
|
454
|
+
}
|
|
455
|
+
async waitPaginationChange(before, timeoutMs = 8000) {
|
|
456
|
+
const started = Date.now();
|
|
457
|
+
const deadline = started + Math.min(Math.max(timeoutMs, 500), 30_000);
|
|
458
|
+
while (Date.now() < deadline) {
|
|
459
|
+
await new Promise((resolve) => setTimeout(resolve, 220));
|
|
460
|
+
try {
|
|
461
|
+
if ((await this.paginationSignature()) !== before) {
|
|
462
|
+
return {
|
|
463
|
+
moved: true,
|
|
464
|
+
state: await this.pagination(),
|
|
465
|
+
elapsedMs: Date.now() - started,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
/* navigation during the poll - retry */
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
moved: false,
|
|
475
|
+
state: await this.pagination().catch(() => ({
|
|
476
|
+
kind: 'none',
|
|
477
|
+
hasNext: false,
|
|
478
|
+
hasPrev: false,
|
|
479
|
+
rowCount: 0,
|
|
480
|
+
pageSizeOptions: [],
|
|
481
|
+
controls: [],
|
|
482
|
+
})),
|
|
483
|
+
elapsedMs: Date.now() - started,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
async clickPagination(kind, page) {
|
|
487
|
+
const state = await this.pagination();
|
|
488
|
+
const control = state.controls.find((c) => c.kind === kind && (page === undefined || c.page === page)) ??
|
|
489
|
+
(kind === 'next'
|
|
490
|
+
? state.controls.find((c) => c.kind === 'loadMore')
|
|
491
|
+
: undefined);
|
|
492
|
+
if (!control || control.disabled)
|
|
493
|
+
return { clicked: false };
|
|
494
|
+
await (0, cdp_1.clickAt)(this.page, control.x, control.y);
|
|
495
|
+
return { clicked: true, control };
|
|
496
|
+
}
|
|
497
|
+
/** Move to the next page (or Load more) and wait for the content to change. */
|
|
498
|
+
async nextPage(timeoutMs = 8000) {
|
|
499
|
+
this.ensureConnected();
|
|
500
|
+
const before = await this.paginationSignature();
|
|
501
|
+
const clicked = await this.clickPagination('next');
|
|
502
|
+
if (!clicked.clicked) {
|
|
503
|
+
return {
|
|
504
|
+
moved: false,
|
|
505
|
+
elapsedMs: 0,
|
|
506
|
+
state: await this.pagination(),
|
|
507
|
+
reason: 'no enabled next/load-more control',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const result = await this.waitPaginationChange(before, timeoutMs);
|
|
511
|
+
return {
|
|
512
|
+
...result,
|
|
513
|
+
...(result.moved ? {} : { reason: 'page did not change' }),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** Move to the previous page. */
|
|
517
|
+
async prevPage(timeoutMs = 8000) {
|
|
518
|
+
this.ensureConnected();
|
|
519
|
+
const before = await this.paginationSignature();
|
|
520
|
+
const clicked = await this.clickPagination('prev');
|
|
521
|
+
if (!clicked.clicked) {
|
|
522
|
+
return {
|
|
523
|
+
moved: false,
|
|
524
|
+
elapsedMs: 0,
|
|
525
|
+
state: await this.pagination(),
|
|
526
|
+
reason: 'no enabled previous control',
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const result = await this.waitPaginationChange(before, timeoutMs);
|
|
530
|
+
return {
|
|
531
|
+
...result,
|
|
532
|
+
...(result.moved ? {} : { reason: 'page did not change' }),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/** Go to a numbered page; walks next if the page control is not rendered. */
|
|
536
|
+
async gotoPage(page, timeoutMs = 8000) {
|
|
537
|
+
this.ensureConnected();
|
|
538
|
+
const target = Math.max(1, Math.floor(page));
|
|
539
|
+
const current = await this.pagination();
|
|
540
|
+
if (current.page === target) {
|
|
541
|
+
return {
|
|
542
|
+
moved: false,
|
|
543
|
+
elapsedMs: 0,
|
|
544
|
+
state: current,
|
|
545
|
+
reason: 'already on page',
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const before = await this.paginationSignature();
|
|
549
|
+
const direct = await this.clickPagination('page', target);
|
|
550
|
+
if (direct.clicked) {
|
|
551
|
+
const result = await this.waitPaginationChange(before, timeoutMs);
|
|
552
|
+
return {
|
|
553
|
+
...result,
|
|
554
|
+
...(result.moved ? {} : { reason: 'page did not change' }),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
// Walk forward (bounded) when only prev/next controls exist.
|
|
558
|
+
for (let i = 0; i < Math.min(target, 100); i += 1) {
|
|
559
|
+
const next = await this.nextPage(timeoutMs);
|
|
560
|
+
if (!next.moved)
|
|
561
|
+
return { ...next, reason: next.reason ?? 'page did not change' };
|
|
562
|
+
if (next.state.page === target)
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
moved: false,
|
|
567
|
+
elapsedMs: 0,
|
|
568
|
+
state: await this.pagination(),
|
|
569
|
+
reason: 'page control not found and the target was not reached',
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/** Select a native page-size option (custom comboboxes use click + waitFor). */
|
|
573
|
+
async setPageSize(size) {
|
|
574
|
+
this.ensureConnected();
|
|
575
|
+
const before = await this.paginationSignature();
|
|
576
|
+
const changed = await (0, cdp_1.evaluateJs)(this.page, `(() => {
|
|
577
|
+
const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
578
|
+
const target = ${Math.max(1, Math.floor(size))};
|
|
579
|
+
for (const sel of [...document.querySelectorAll('select')].filter(vis)) {
|
|
580
|
+
const options = [...sel.options];
|
|
581
|
+
const option = options.find((o) => Number(String(o.textContent || o.label || '').trim()) === target);
|
|
582
|
+
if (!option) continue;
|
|
583
|
+
const proto = HTMLSelectElement.prototype;
|
|
584
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
585
|
+
if (desc && desc.set) desc.set.call(sel, option.value);
|
|
586
|
+
else sel.value = option.value;
|
|
587
|
+
sel.dispatchEvent(new Event('input', { bubbles: true }));
|
|
588
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
})()`);
|
|
593
|
+
if (changed !== true) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
state: await this.pagination(),
|
|
597
|
+
error: 'page size is not a native select; use getElements + click on the custom combobox',
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const result = await this.waitPaginationChange(before);
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
state: result.state,
|
|
604
|
+
...(result.moved ? {} : { error: 'page did not visibly change' }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Read the current table (headers + rows), then walk every page and return
|
|
609
|
+
* the deduplicated rows. Bounded by maxPages/maxRows; stops on a repeated
|
|
610
|
+
* page signature, a disabled pager, or an unchanged table.
|
|
611
|
+
*/
|
|
612
|
+
async collectTable(opts) {
|
|
613
|
+
this.ensureConnected();
|
|
614
|
+
if (opts?.pageSize)
|
|
615
|
+
await this.setPageSize(opts.pageSize);
|
|
616
|
+
const maxPages = Math.min(Math.max(opts?.maxPages ?? 50, 1), 200);
|
|
617
|
+
const maxRows = Math.min(Math.max(opts?.maxRows ?? 2000, 1), 10_000);
|
|
618
|
+
const seen = new Set();
|
|
619
|
+
const rows = [];
|
|
620
|
+
let headers = [];
|
|
621
|
+
let duplicates = 0;
|
|
622
|
+
let pagesVisited = 0;
|
|
623
|
+
let stoppedBecause = 'unknown';
|
|
624
|
+
let truncated = false;
|
|
625
|
+
const readTable = async () => {
|
|
626
|
+
try {
|
|
627
|
+
const raw = await (0, cdp_1.evaluateJs)(this.page, `(() => {
|
|
628
|
+
const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
629
|
+
const selector = ${JSON.stringify(opts?.selector ?? '')};
|
|
630
|
+
let table = selector ? document.querySelector(selector) : null;
|
|
631
|
+
if (!table) {
|
|
632
|
+
const tables = [...document.querySelectorAll('table,[role="table"],[role="grid"]')].filter(vis);
|
|
633
|
+
table = tables.sort((a, b) => b.querySelectorAll('tr,[role="row"]').length - a.querySelectorAll('tr,[role="row"]').length)[0] || null;
|
|
634
|
+
}
|
|
635
|
+
if (!table) return null;
|
|
636
|
+
const headers = [...table.querySelectorAll('thead th,[role="columnheader"]')].map((h) => String(h.innerText || h.textContent || '').replace(/\\s+/g, ' ').trim()).filter(Boolean);
|
|
637
|
+
const bodyRows = [...table.querySelectorAll('tbody tr,[role="row"]')].filter(vis)
|
|
638
|
+
.filter((r) => r.querySelectorAll('td,[role="gridcell"],[role="cell"]').length > 0);
|
|
639
|
+
return {
|
|
640
|
+
headers,
|
|
641
|
+
rows: bodyRows.map((r) => [...r.querySelectorAll('td,[role="gridcell"],[role="cell"]')].map((c) => String(c.innerText || c.textContent || '').replace(/\\s+/g, ' ').trim())),
|
|
642
|
+
};
|
|
643
|
+
})()`);
|
|
644
|
+
if (!raw || typeof raw !== 'object')
|
|
645
|
+
return null;
|
|
646
|
+
return raw;
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
653
|
+
const table = await readTable();
|
|
654
|
+
if (!table) {
|
|
655
|
+
stoppedBecause = 'no table found';
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
pagesVisited += 1;
|
|
659
|
+
if (table.headers.length)
|
|
660
|
+
headers = table.headers;
|
|
661
|
+
for (const row of table.rows) {
|
|
662
|
+
const key = row.join('\u0001');
|
|
663
|
+
if (seen.has(key)) {
|
|
664
|
+
duplicates += 1;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
seen.add(key);
|
|
668
|
+
rows.push(row);
|
|
669
|
+
if (rows.length >= maxRows) {
|
|
670
|
+
truncated = true;
|
|
671
|
+
stoppedBecause = 'maxRows';
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (truncated)
|
|
676
|
+
break;
|
|
677
|
+
const next = await this.nextPage();
|
|
678
|
+
if (!next.moved) {
|
|
679
|
+
stoppedBecause = next.reason ?? 'end of pages';
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const state = await this.pagination().catch(() => null);
|
|
684
|
+
return {
|
|
685
|
+
headers,
|
|
686
|
+
rows,
|
|
687
|
+
rowCount: rows.length,
|
|
688
|
+
pagesVisited,
|
|
689
|
+
...(state?.total ? { total: state.total } : {}),
|
|
690
|
+
complete: !truncated &&
|
|
691
|
+
(stoppedBecause === 'end of pages' ||
|
|
692
|
+
stoppedBecause === 'no enabled next/load-more control'),
|
|
693
|
+
duplicates,
|
|
694
|
+
truncated,
|
|
695
|
+
stoppedBecause,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
402
698
|
async navigate(url) {
|
|
403
699
|
this.ensureConnected();
|
|
404
700
|
return (0, cdp_1.navigateTo)(this.page, url);
|
|
@@ -408,10 +704,28 @@ class BrowserSession {
|
|
|
408
704
|
*/
|
|
409
705
|
async screenshot(format = 'jpeg', quality = 70) {
|
|
410
706
|
this.ensureConnected();
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
707
|
+
return (0, cdp_1.captureScreenshotChecked)(this.page, format, quality);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Capture a screenshot with numbered ref labels drawn over the interactive
|
|
711
|
+
* elements, then remove the overlay (2026-10). One image shows the page AND
|
|
712
|
+
* names every target; the page is never left modified.
|
|
713
|
+
*/
|
|
714
|
+
async annotatedScreenshot(format = 'jpeg', quality = 70) {
|
|
715
|
+
this.ensureConnected();
|
|
716
|
+
await this.getRefs();
|
|
717
|
+
try {
|
|
718
|
+
await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildAnnotateScript)());
|
|
719
|
+
return await (0, cdp_1.captureScreenshotChecked)(this.page, format, quality);
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
try {
|
|
723
|
+
await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildClearAnnotationScript)());
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
/* overlay cleanup is best-effort; it is pointer-events:none */
|
|
727
|
+
}
|
|
728
|
+
}
|
|
415
729
|
}
|
|
416
730
|
/**
|
|
417
731
|
* Read the network + console evidence collected for this session (QA).
|
|
@@ -423,12 +737,211 @@ class BrowserSession {
|
|
|
423
737
|
return this.network.snapshot(this.page, opts);
|
|
424
738
|
}
|
|
425
739
|
/**
|
|
426
|
-
* Click at (x, y)
|
|
740
|
+
* Click at (x, y) CSS pixels. Coordinates OUTSIDE the current viewport are
|
|
741
|
+
* refused loudly — a screenshot pixel is not a CSS pixel (the capture is
|
|
742
|
+
* device-scale), so clicking one lands on the wrong element.
|
|
427
743
|
*/
|
|
428
744
|
async click(x, y) {
|
|
429
745
|
this.ensureConnected();
|
|
746
|
+
const vp = await (0, cdp_1.viewportOf)(this.page);
|
|
747
|
+
if (vp && (x < 0 || y < 0 || x > vp.width || y > vp.height)) {
|
|
748
|
+
throw new Error(`CLICK_OUT_OF_VIEWPORT: (${Math.round(x)}, ${Math.round(y)}) is outside the ${vp.width}x${vp.height} CSS viewport. Get fresh coordinates from getElements or snapshot fields (CSS pixels, never screenshot pixels — the capture is device-scale, ~${vp.dpr}x), or click an element by selector instead.`);
|
|
749
|
+
}
|
|
430
750
|
await (0, cdp_1.clickAt)(this.page, x, y);
|
|
431
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Click by reference instead of raw coordinates: an element REF (`e7`, from
|
|
754
|
+
* getRefs / snapshot refs:true), explicit x/y, a fresh `getElements` index,
|
|
755
|
+
* or a CSS selector resolved in-page. Coordinates from a screenshot image are
|
|
756
|
+
* NEVER accepted here — they are device-scale.
|
|
757
|
+
*/
|
|
758
|
+
async clickRef(ref) {
|
|
759
|
+
this.ensureConnected();
|
|
760
|
+
if (typeof ref.ref === 'string' && ref.ref) {
|
|
761
|
+
const pt = await this.resolveRefPoint(ref.ref);
|
|
762
|
+
await this.click(pt.x, pt.y);
|
|
763
|
+
return {
|
|
764
|
+
x: pt.x,
|
|
765
|
+
y: pt.y,
|
|
766
|
+
...(pt.refreshed ? { refreshedFrom: ref.ref } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (typeof ref.x === 'number' && typeof ref.y === 'number') {
|
|
770
|
+
await this.click(ref.x, ref.y);
|
|
771
|
+
return { x: ref.x, y: ref.y };
|
|
772
|
+
}
|
|
773
|
+
if (typeof ref.elementIndex === 'number') {
|
|
774
|
+
const elements = await this.getElements();
|
|
775
|
+
const el = elements[ref.elementIndex];
|
|
776
|
+
if (!el) {
|
|
777
|
+
throw new Error(`ELEMENT_INDEX_OUT_OF_RANGE: getElements returned ${elements.length} elements, no index ${ref.elementIndex}. Re-read getElements — the page changed since the last list.`);
|
|
778
|
+
}
|
|
779
|
+
await this.click(el.x, el.y);
|
|
780
|
+
return { x: el.x, y: el.y };
|
|
781
|
+
}
|
|
782
|
+
if (typeof ref.selector === 'string' && ref.selector) {
|
|
783
|
+
const pt = await (0, cdp_1.evaluateJs)(this.page, `(() => { const el = document.querySelector(${JSON.stringify(ref.selector)}); if (!el) return null; const r = el.getBoundingClientRect(); if (!r || r.width === 0 && r.height === 0) return null; return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`);
|
|
784
|
+
if (!pt || typeof pt.x !== 'number' || typeof pt.y !== 'number') {
|
|
785
|
+
throw new Error(`SELECTOR_NOT_FOUND: no visible element matches the selector. Re-check with getElements or snapshot.`);
|
|
786
|
+
}
|
|
787
|
+
await this.click(pt.x, pt.y);
|
|
788
|
+
return { x: pt.x, y: pt.y };
|
|
789
|
+
}
|
|
790
|
+
throw new Error('CLICK_TARGET_REQUIRED: pass x/y, elementIndex or selector.');
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Fresh element-ref scan (2026-10).
|
|
794
|
+
*
|
|
795
|
+
* Returns the interactive elements of the CURRENT page as `e1 … eN` with
|
|
796
|
+
* role, accessible name, state and CSS-pixel centre. Includes elements
|
|
797
|
+
* inside portalled menus/popovers/dialogs, which is the whole point: a kebab
|
|
798
|
+
* menu that just opened is findable without guessing a selector.
|
|
799
|
+
*/
|
|
800
|
+
async getRefs() {
|
|
801
|
+
this.ensureConnected();
|
|
802
|
+
const { at, list } = await (0, refs_1.scanRefs)(this.page);
|
|
803
|
+
this._lastRefs = list;
|
|
804
|
+
return { at, count: list.length, refs: list, lines: (0, refs_1.formatRefs)(list) };
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Resolve a ref to a clickable point. A ref whose node left the DOM is
|
|
808
|
+
* either re-pointed at a UNIQUE same-role/same-name element from a fresh
|
|
809
|
+
* scan (the common case: a re-render replaced the node), or refused with
|
|
810
|
+
* `REF_STALE` - never silently clicked at stale coordinates.
|
|
811
|
+
*/
|
|
812
|
+
async resolveRefPoint(ref) {
|
|
813
|
+
const info = await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildRefInfoScript)(ref));
|
|
814
|
+
if (info && info.alive === true && typeof info.x === 'number') {
|
|
815
|
+
return { x: Math.round(info.x), y: Math.round(info.y), refreshed: false };
|
|
816
|
+
}
|
|
817
|
+
const previous = this._lastRefs.find((r) => r.ref === ref);
|
|
818
|
+
const { list } = await (0, refs_1.scanRefs)(this.page);
|
|
819
|
+
this._lastRefs = list;
|
|
820
|
+
const candidates = previous
|
|
821
|
+
? list.filter((r) => r.role === previous.role && r.name === previous.name)
|
|
822
|
+
: [];
|
|
823
|
+
if (candidates.length === 1) {
|
|
824
|
+
return { x: candidates[0].x, y: candidates[0].y, refreshed: true };
|
|
825
|
+
}
|
|
826
|
+
throw new Error(`REF_STALE: ${ref}${previous?.name ? ` (${previous.role} "${previous.name}")` : ''} is no longer on the page. ` +
|
|
827
|
+
'Call browser_action({action:"getRefs"}) or snapshot {refs:true} for a fresh list and use the new ref - never guess a selector.');
|
|
828
|
+
}
|
|
829
|
+
/** Move the pointer to (x, y) without clicking (hover menus/tooltips). */
|
|
830
|
+
async hover(x, y) {
|
|
831
|
+
this.ensureConnected();
|
|
832
|
+
await (0, cdp_1.hoverAt)(this.page, x, y);
|
|
833
|
+
}
|
|
834
|
+
/** Hover an element by ref (preferred) or CSS selector. */
|
|
835
|
+
async hoverRef(ref) {
|
|
836
|
+
this.ensureConnected();
|
|
837
|
+
if (typeof ref.ref === 'string' && ref.ref) {
|
|
838
|
+
const pt = await this.resolveRefPoint(ref.ref);
|
|
839
|
+
await this.hover(pt.x, pt.y);
|
|
840
|
+
return {
|
|
841
|
+
x: pt.x,
|
|
842
|
+
y: pt.y,
|
|
843
|
+
...(pt.refreshed ? { refreshedFrom: ref.ref } : {}),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (typeof ref.selector === 'string' && ref.selector) {
|
|
847
|
+
const pt = await (0, cdp_1.evaluateJs)(this.page, `(() => { const el = document.querySelector(${JSON.stringify(ref.selector)}); if (!el) return null; const r = el.getBoundingClientRect(); if (!r || (r.width === 0 && r.height === 0)) return null; return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`);
|
|
848
|
+
if (!pt || typeof pt.x !== 'number' || typeof pt.y !== 'number') {
|
|
849
|
+
throw new Error('SELECTOR_NOT_FOUND: no visible element matches the selector. Re-check getRefs or snapshot.');
|
|
850
|
+
}
|
|
851
|
+
await this.hover(pt.x, pt.y);
|
|
852
|
+
return { x: pt.x, y: pt.y };
|
|
853
|
+
}
|
|
854
|
+
throw new Error('HOVER_TARGET_REQUIRED: pass ref or selector.');
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Click a ref'd element with the IN-PAGE pointer sequence instead of a CDP
|
|
858
|
+
* coordinate click (2026-10). This is the rescue for a control that ignores
|
|
859
|
+
* a normal click (the Radix lesson from the web tours: some activators only
|
|
860
|
+
* react to a full pointerdown/mousedown gesture). Explicit, never automatic:
|
|
861
|
+
* a blind second click after "nothing happened" could double-submit a form.
|
|
862
|
+
*/
|
|
863
|
+
async clickPointerRef(ref) {
|
|
864
|
+
this.ensureConnected();
|
|
865
|
+
const result = await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildRefPointerScript)(ref));
|
|
866
|
+
if (!result || result.alive === false) {
|
|
867
|
+
throw new Error(`REF_STALE: ${ref} is no longer on the page. Call getRefs or snapshot {refs:true} for a fresh list.`);
|
|
868
|
+
}
|
|
869
|
+
if (result.ok !== true) {
|
|
870
|
+
throw new Error(`POINTER_CLICK_FAILED: ${ref} could not be clicked in-page. Re-read the screen and retry.`);
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
x: Math.round(Number(result.x) || 0),
|
|
874
|
+
y: Math.round(Number(result.y) || 0),
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Focus an element by ref (2026-10). Reports whether focus actually moved:
|
|
879
|
+
* a plain div without tabindex cannot take focus, and claiming `ok:true`
|
|
880
|
+
* while the active element is unchanged is how a QA step "passes" having
|
|
881
|
+
* done nothing.
|
|
882
|
+
*/
|
|
883
|
+
async focusRef(ref) {
|
|
884
|
+
this.ensureConnected();
|
|
885
|
+
let info = await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildRefFocusScript)(ref));
|
|
886
|
+
if (info && info.alive === false) {
|
|
887
|
+
// Re-point through the same staleness ladder as click, then focus the
|
|
888
|
+
// element that now occupies those coordinates (refs were re-minted).
|
|
889
|
+
const pt = await this.resolveRefPoint(ref);
|
|
890
|
+
const target = this._lastRefs.find((r) => r.x === pt.x && r.y === pt.y);
|
|
891
|
+
if (!target) {
|
|
892
|
+
return { ok: false, alive: false };
|
|
893
|
+
}
|
|
894
|
+
info = await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildRefFocusScript)(target.ref));
|
|
895
|
+
return {
|
|
896
|
+
ok: info?.ok === true,
|
|
897
|
+
alive: info?.alive !== false,
|
|
898
|
+
...(typeof info?.tag === 'string' ? { tag: info.tag } : {}),
|
|
899
|
+
...(info?.contentEditable === true ? { contentEditable: true } : {}),
|
|
900
|
+
refreshedFrom: ref,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
ok: info?.ok === true,
|
|
905
|
+
alive: info?.alive !== false,
|
|
906
|
+
...(typeof info?.tag === 'string' ? { tag: info.tag } : {}),
|
|
907
|
+
...(info?.contentEditable === true ? { contentEditable: true } : {}),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Close whatever menu/popover layer is open (2026-10): Escape first (Radix
|
|
912
|
+
* DismissableLayer), then a document-level outside pointer sequence for
|
|
913
|
+
* layers that ignore Escape. Reports what is left, so a "still open" menu is
|
|
914
|
+
* visible instead of silently swallowing the next click.
|
|
915
|
+
*/
|
|
916
|
+
async closePopup() {
|
|
917
|
+
this.ensureConnected();
|
|
918
|
+
let left = await this.countPopovers();
|
|
919
|
+
if (left > 0) {
|
|
920
|
+
await (0, cdp_1.pressKey)(this.page, 'Escape');
|
|
921
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
922
|
+
left = await this.countPopovers();
|
|
923
|
+
}
|
|
924
|
+
if (left > 0) {
|
|
925
|
+
try {
|
|
926
|
+
await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildOutsidePointerScript)());
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
/* below reports what is still open */
|
|
930
|
+
}
|
|
931
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
932
|
+
left = await this.countPopovers();
|
|
933
|
+
}
|
|
934
|
+
return { closed: left === 0, popoversLeft: left };
|
|
935
|
+
}
|
|
936
|
+
async countPopovers() {
|
|
937
|
+
try {
|
|
938
|
+
const n = await (0, cdp_1.evaluateJs)(this.page, (0, refs_1.buildPopoverCountScript)());
|
|
939
|
+
return typeof n === 'number' ? n : 0;
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return 0;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
432
945
|
/**
|
|
433
946
|
* Type text at the current cursor position, VERIFYING it landed.
|
|
434
947
|
*
|
|
@@ -436,6 +949,9 @@ class BrowserSession {
|
|
|
436
949
|
* caller opted in (`browser_open({ allowCredentials: true })`) AND passed a
|
|
437
950
|
* credential for the run - the debug browser is the QA surface, the
|
|
438
951
|
* auto-answer loop is a separate path that still never touches credentials.
|
|
952
|
+
*
|
|
953
|
+
* An insert the page does not reflect is refused with `TYPE_NOT_VERIFIED`
|
|
954
|
+
* (carrying the observed field state) — never reported as success.
|
|
439
955
|
*/
|
|
440
956
|
async type(text, opts) {
|
|
441
957
|
this.ensureConnected();
|
|
@@ -443,7 +959,11 @@ class BrowserSession {
|
|
|
443
959
|
if (credential && !this._allowCredentials) {
|
|
444
960
|
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.');
|
|
445
961
|
}
|
|
446
|
-
await (0, login_1.typeTextVerified)(this.page, text, opts);
|
|
962
|
+
const result = await (0, login_1.typeTextVerified)(this.page, text, opts);
|
|
963
|
+
if (!result.ok) {
|
|
964
|
+
throw new Error(`TYPE_NOT_VERIFIED: the typed text did not land (focused: ${result.focused ?? 'nothing focusable'}${result.contentEditable ? ' [contenteditable]' : ''}, before: ${result.beforeLength ?? 'unreadable'} chars, after: ${result.afterLength ?? 'unreadable'} chars). Read the field back via snapshot (focused/valueLength) and retry with clearFirst:true, or fill it with evaluate's native value setter + input/change events.`);
|
|
965
|
+
}
|
|
966
|
+
return result;
|
|
447
967
|
}
|
|
448
968
|
/**
|
|
449
969
|
* Deterministically fill a login form (username + password) and submit.
|
|
@@ -769,7 +1289,7 @@ class BrowserSession {
|
|
|
769
1289
|
})));
|
|
770
1290
|
}
|
|
771
1291
|
const state = await (0, page_context_1.readPageState)(this.page, opts);
|
|
772
|
-
|
|
1292
|
+
const base = {
|
|
773
1293
|
...state,
|
|
774
1294
|
nativeDialog: this._nativeDialog,
|
|
775
1295
|
pendingRequests: this.network.pendingCount(),
|
|
@@ -779,6 +1299,15 @@ class BrowserSession {
|
|
|
779
1299
|
at: c.at,
|
|
780
1300
|
})),
|
|
781
1301
|
};
|
|
1302
|
+
if (opts?.refs === true && !this._nativeDialog) {
|
|
1303
|
+
const scanned = await this.getRefs();
|
|
1304
|
+
return {
|
|
1305
|
+
...base,
|
|
1306
|
+
refs: scanned.refs,
|
|
1307
|
+
refsText: scanned.lines,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
return base;
|
|
782
1311
|
}
|
|
783
1312
|
/**
|
|
784
1313
|
* Bounded state attached to every mutating action result. Never include the
|