pawbrowse 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extension/background.js +1303 -169
- package/extension/manifest.json +2 -2
- package/mcp/server.mjs +15 -5
- package/package.json +3 -2
package/extension/background.js
CHANGED
|
@@ -21,7 +21,8 @@ let reconnectTimer = null;
|
|
|
21
21
|
const attachedTabs = new Set(); // tabIds we currently hold a debugger on
|
|
22
22
|
const sessions = new Map(); // sessionId -> { activeTabId, createdTabs:Set, groupId, num, color }
|
|
23
23
|
const tabOwner = new Map(); // tabId -> sessionId (so sessions don't steal each other's tabs)
|
|
24
|
-
const chains = new Map();
|
|
24
|
+
const chains = new Map();
|
|
25
|
+
const refSeed = new Map(); // tabId -> next unused ref number (see SNAPSHOT: refs never repeat within a tab) // sessionId -> Promise (serialize commands within a session)
|
|
25
26
|
let sessionCounter = 0;
|
|
26
27
|
let colorCursor = 0;
|
|
27
28
|
// PawBrowse's own group identity — deliberately NOT Claude-in-Chrome's blue "Claude" group.
|
|
@@ -44,7 +45,7 @@ function persistState() {
|
|
|
44
45
|
try {
|
|
45
46
|
const ser = {};
|
|
46
47
|
for (const [k, v] of sessions) ser[k] = { activeTabId: v.activeTabId, createdTabs: [...v.createdTabs], groupId: v.groupId, num: v.num, color: v.color };
|
|
47
|
-
await chrome.storage.session.set({ pawbrowse_state: { sessions: ser, tabOwner: [...tabOwner], sessionCounter, colorCursor } });
|
|
48
|
+
await chrome.storage.session.set({ pawbrowse_state: { sessions: ser, tabOwner: [...tabOwner], sessionCounter, colorCursor, refSeed: [...refSeed] } });
|
|
48
49
|
} catch {}
|
|
49
50
|
}, 250);
|
|
50
51
|
persistTimer.unref?.();
|
|
@@ -59,6 +60,7 @@ const rehydrated = (async () => {
|
|
|
59
60
|
for (const [t, sess] of (st.tabOwner || [])) if (!tabOwner.has(t)) tabOwner.set(t, sess);
|
|
60
61
|
if (typeof st.sessionCounter === 'number') sessionCounter = Math.max(sessionCounter, st.sessionCounter);
|
|
61
62
|
if (typeof st.colorCursor === 'number') colorCursor = Math.max(colorCursor, st.colorCursor);
|
|
63
|
+
for (const [t, n] of (st.refSeed || [])) refSeed.set(t, Math.max(refSeed.get(t) || 1, n));
|
|
62
64
|
} catch {}
|
|
63
65
|
})();
|
|
64
66
|
|
|
@@ -117,12 +119,17 @@ async function connect() {
|
|
|
117
119
|
// navigate) stops issuing further CDP ops instead of leaking work into the next one.
|
|
118
120
|
const token = { cancelled: false };
|
|
119
121
|
const prev = chains.get(session) || Promise.resolve();
|
|
122
|
+
let settled = null;
|
|
120
123
|
const run = prev.then(async () => {
|
|
121
124
|
let timer;
|
|
122
125
|
try {
|
|
126
|
+
const work = handleCommand(msg.cmd, msg.args || {}, token, session);
|
|
127
|
+
// The next command in this session must not start while this one is still touching the tab,
|
|
128
|
+
// even after we've replied with a timeout (bounded, so a wedged page can't block forever).
|
|
129
|
+
settled = Promise.race([work.catch(() => {}), sleep(15000)]);
|
|
123
130
|
const result = await Promise.race([
|
|
124
|
-
|
|
125
|
-
new Promise((_, rej) => { timer = setTimeout(() => { token.cancelled = true; rej(new Error(
|
|
131
|
+
work,
|
|
132
|
+
new Promise((_, rej) => { timer = setTimeout(() => { token.cancelled = true; rej(new Error(`command timed out in extension after 25s${msg.cmd === 'act' || msg.cmd === 'navigate' ? ' — it may already have acted: observe before retrying' : ''}`)); }, 25000); }),
|
|
126
133
|
]);
|
|
127
134
|
clearTimeout(timer);
|
|
128
135
|
sock.send(JSON.stringify({ id: msg.id, ok: true, result }));
|
|
@@ -131,7 +138,7 @@ async function connect() {
|
|
|
131
138
|
try { sock.send(JSON.stringify({ id: msg.id, ok: false, error: String(e && e.message || e) })); } catch {}
|
|
132
139
|
}
|
|
133
140
|
});
|
|
134
|
-
chains.set(session, run.catch(() => {}));
|
|
141
|
+
chains.set(session, run.catch(() => {}).then(() => settled));
|
|
135
142
|
};
|
|
136
143
|
ws.onclose = () => { setBadge('off'); scheduleReconnect(); };
|
|
137
144
|
ws.onerror = () => { try { ws.close(); } catch {} };
|
|
@@ -151,21 +158,61 @@ function setBadge(state) {
|
|
|
151
158
|
|
|
152
159
|
/* ------------------------------- CDP helpers ------------------------------ */
|
|
153
160
|
|
|
154
|
-
|
|
161
|
+
// A CDP target is a tabId (the tab's top frame) or { tabId, sessionId?, frameId? }: sessionId
|
|
162
|
+
// addresses an out-of-process (cross-site) iframe attached via Target.setAutoAttach (flat sessions,
|
|
163
|
+
// Chrome 125+); frameId pins evaluation to a cross-origin frame living in that session's process.
|
|
164
|
+
const tabOf = (t) => (typeof t === 'object' ? t.tabId : t);
|
|
165
|
+
function sendCdp(target, method, params = {}) {
|
|
166
|
+
const dbg = typeof target === 'object' ? (target.sessionId ? { tabId: target.tabId, sessionId: target.sessionId } : { tabId: target.tabId }) : { tabId: target };
|
|
155
167
|
return new Promise((resolve, reject) => {
|
|
156
|
-
chrome.debugger.sendCommand(
|
|
168
|
+
chrome.debugger.sendCommand(dbg, method, params, (res) => {
|
|
157
169
|
const err = chrome.runtime.lastError;
|
|
158
170
|
if (err) reject(new Error(err.message)); else resolve(res);
|
|
159
171
|
});
|
|
160
172
|
});
|
|
161
173
|
}
|
|
162
174
|
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
// All of PawBrowse's page-side code runs in its own ISOLATED WORLD (like an extension content
|
|
176
|
+
// script / Playwright's utility world): it shares the DOM with the page but not its JS globals, so a
|
|
177
|
+
// page that monkey-patches Array.prototype / JSON / Element.prototype, or that squats on our cache
|
|
178
|
+
// name, can't blind or steer the snapshot. One world per tab's main frame; a navigation destroys it
|
|
179
|
+
// and the next call transparently recreates it.
|
|
180
|
+
const worlds = new Map(); // tabId -> Map(frameKey -> executionContextId)
|
|
181
|
+
const frameKey = (t) => (typeof t === 'object' ? `${t.sessionId || ''}|${t.frameId || ''}` : '|');
|
|
182
|
+
|
|
183
|
+
async function worldFor(target) {
|
|
184
|
+
const tabId = tabOf(target), key = frameKey(target);
|
|
185
|
+
let m = worlds.get(tabId);
|
|
186
|
+
if (!m) { m = new Map(); worlds.set(tabId, m); }
|
|
187
|
+
const cached = m.get(key);
|
|
188
|
+
if (cached != null) return cached;
|
|
189
|
+
let frameId = typeof target === 'object' ? target.frameId : null;
|
|
190
|
+
if (!frameId) ({ frameTree: { frame: { id: frameId } } } = await sendCdp(target, 'Page.getFrameTree'));
|
|
191
|
+
const { executionContextId } = await sendCdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'pawbrowse' });
|
|
192
|
+
m.set(key, executionContextId);
|
|
193
|
+
return executionContextId;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function evaluate(target, expression, opts) {
|
|
197
|
+
let r;
|
|
198
|
+
for (let attempt = 0; ; attempt++) {
|
|
199
|
+
const contextId = await worldFor(target);
|
|
200
|
+
try {
|
|
201
|
+
r = await sendCdp(target, 'Runtime.evaluate', { expression, contextId, returnByValue: !(opts && opts.handle), awaitPromise: true });
|
|
202
|
+
break;
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// The world died with its previous document (navigation/reload) BEFORE this call: make a fresh
|
|
205
|
+
// one and retry once. NOT when the context was destroyed DURING the call ("Execution context
|
|
206
|
+
// was destroyed") — the expression may have already acted (e.g. a select that navigated) and
|
|
207
|
+
// re-running it on the new page would act twice.
|
|
208
|
+
worlds.get(tabOf(target))?.delete(frameKey(target));
|
|
209
|
+
if (attempt >= 1 || !/Cannot find context/i.test(e.message)) throw e;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
165
212
|
if (r && r.exceptionDetails) {
|
|
166
213
|
throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'evaluation error');
|
|
167
214
|
}
|
|
168
|
-
return r.result.value;
|
|
215
|
+
return opts && opts.handle ? r.result : r.result.value;
|
|
169
216
|
}
|
|
170
217
|
|
|
171
218
|
// Attach to a SPECIFIC tab and keep it attached (many tabs can be attached at once, one per
|
|
@@ -193,6 +240,15 @@ async function attach(tabId) {
|
|
|
193
240
|
await sendCdp(tabId, 'Runtime.enable', {}).catch(() => {});
|
|
194
241
|
await sendCdp(tabId, 'Page.enable', {}).catch(() => {});
|
|
195
242
|
await sendCdp(tabId, 'DOM.enable', {}).catch(() => {});
|
|
243
|
+
// Lifecycle events (cheap: a handful per load) tell us when a new document is ready. The Network
|
|
244
|
+
// domain is NOT left on: on ad-heavy pages (hundreds of requests) it slows the whole browser, so
|
|
245
|
+
// it's switched on only around actions (netOn/netOff), where "did this click start a fetch?" matters.
|
|
246
|
+
await sendCdp(tabId, 'Page.setLifecycleEventsEnabled', { enabled: true }).catch(() => {});
|
|
247
|
+
try { const { frameTree } = await sendCdp(tabId, 'Page.getFrameTree'); tabWatch(tabId).mainFrame = frameTree.frame.id; } catch {}
|
|
248
|
+
// WebMCP (sites exposing their own agent tools; Chrome 146+ behind a flag / origin trial).
|
|
249
|
+
await sendCdp(tabId, 'WebMCP.enable', {}).catch(() => {});
|
|
250
|
+
// Cross-site iframes run in other renderer processes: attach to each as a flat child session.
|
|
251
|
+
await sendCdp(tabId, 'Target.setAutoAttach', AUTO_ATTACH).catch(() => {});
|
|
196
252
|
// Make the tab behave as focused even when it's a background tab, so focus/blur, rendering,
|
|
197
253
|
// and focus-dependent menus/dropdowns work while driving (the same approach Playwright uses for
|
|
198
254
|
// backgrounded pages). A hidden tab still throttles requestAnimationFrame, so our waits use
|
|
@@ -201,15 +257,71 @@ async function attach(tabId) {
|
|
|
201
257
|
}
|
|
202
258
|
|
|
203
259
|
function detach(tabId) {
|
|
204
|
-
return new Promise((resolve) => chrome.debugger.detach({ tabId }, () => { void chrome.runtime.lastError; attachedTabs.delete(tabId); resolve(); }));
|
|
260
|
+
return new Promise((resolve) => chrome.debugger.detach({ tabId }, () => { void chrome.runtime.lastError; attachedTabs.delete(tabId); forgetTab(tabId, false); resolve(); }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Everything we remember about a tab's CDP state. On detach (user cancelled the debugging bar,
|
|
264
|
+
// renderer crash) child sessions are gone without detachedFromTarget events: drop them so a
|
|
265
|
+
// re-attach rebuilds cleanly. On tab close, also drop what outlives an attachment.
|
|
266
|
+
function forgetTab(tabId, closed) {
|
|
267
|
+
worlds.delete(tabId); childSessions.delete(tabId); webTools.delete(tabId); shotScale.delete(tabId);
|
|
268
|
+
if (!closed) return;
|
|
269
|
+
watches.delete(tabId); frameIdx.delete(tabId); lastTable.delete(tabId); lastFull.delete(tabId);
|
|
270
|
+
openDialogs.delete(tabId); acting.delete(tabId); dialogLog.delete(tabId); refSeed.delete(tabId);
|
|
271
|
+
for (const k of [...refSeed.keys()]) if (String(k).startsWith(`${tabId}#`)) refSeed.delete(k);
|
|
272
|
+
}
|
|
273
|
+
chrome.debugger.onDetach.addListener((source) => { if (source.tabId != null) { attachedTabs.delete(source.tabId); forgetTab(source.tabId, false); } });
|
|
274
|
+
|
|
275
|
+
// JavaScript dialogs (alert/confirm/prompt/beforeunload) block the page's main thread, so every CDP
|
|
276
|
+
// call into the tab would hang until someone clicks the dialog. While PawBrowse is ACTING on a tab
|
|
277
|
+
// it answers them itself: alerts are accepted, confirm/prompt/beforeunload are DISMISSED unless the
|
|
278
|
+
// op opted in with dialog:"accept" (a destructive confirm is never auto-approved). What was shown is
|
|
279
|
+
// reported back. Dialogs that appear while we're idle belong to the user and are left alone; if one
|
|
280
|
+
// is still open when a command arrives we say so instead of hanging.
|
|
281
|
+
const acting = new Map(); // tabId -> { accept?: boolean, text?: string } while an act/navigate runs
|
|
282
|
+
const dialogLog = new Map(); // tabId -> [lines] reported in the next result
|
|
283
|
+
const openDialogs = new Map(); // tabId -> { type, message } left open (appeared while idle)
|
|
284
|
+
|
|
285
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
286
|
+
const tabId = source.tabId;
|
|
287
|
+
if (tabId == null) return;
|
|
288
|
+
if (method === 'Page.javascriptDialogClosed') { openDialogs.delete(tabId); return; }
|
|
289
|
+
if (method !== 'Page.javascriptDialogOpening') return;
|
|
290
|
+
const pol = acting.get(tabId);
|
|
291
|
+
const where = source.sessionId ? { tabId, sessionId: source.sessionId } : tabId; // dialogs can come from frames
|
|
292
|
+
if (!pol) { openDialogs.set(tabId, { type: params.type, message: params.message, where }); return; }
|
|
293
|
+
const accept = pol.accept != null ? pol.accept : params.type === 'alert';
|
|
294
|
+
const promptText = pol.text != null ? String(pol.text) : (params.defaultPrompt || '');
|
|
295
|
+
sendCdp(where, 'Page.handleJavaScriptDialog', { accept, promptText }).catch(() => {});
|
|
296
|
+
const lines = dialogLog.get(tabId) || [];
|
|
297
|
+
lines.push(`${params.type} "${String(params.message || '').replace(/\s+/g, ' ').slice(0, 200)}" → ${accept ? 'accepted' : 'dismissed'}${params.type === 'prompt' && accept ? ` with "${promptText}"` : ''}${!accept && params.type !== 'alert' ? ` (to accept, repeat ${pol.nav ? 'browser_navigate' : 'the op'} with dialog:"accept")` : ''}`);
|
|
298
|
+
dialogLog.set(tabId, lines);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Run fn with this tab's dialogs auto-answered (see above).
|
|
302
|
+
async function whileActing(tabId, fn, policy) {
|
|
303
|
+
const mine = { ...(policy || {}) };
|
|
304
|
+
acting.set(tabId, mine);
|
|
305
|
+
try { return await fn(); } finally { if (acting.get(tabId) === mine) acting.delete(tabId); }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function takeDialogLog(tabId) {
|
|
309
|
+
const l = dialogLog.get(tabId); dialogLog.delete(tabId);
|
|
310
|
+
return l && l.length ? l.map((x) => ` dialog: ${x}`).join('\n') + '\n' : '';
|
|
205
311
|
}
|
|
206
312
|
|
|
207
|
-
|
|
313
|
+
// Refuse to talk to a tab frozen by a dialog the user hasn't answered (it would hang). The
|
|
314
|
+
// act op {op:"dialog"} answers it.
|
|
315
|
+
function assertNoOpenDialog(tabId) {
|
|
316
|
+
const d = openDialogs.get(tabId);
|
|
317
|
+
if (d) throw new Error(`the page is showing ${d.type === 'alert' ? 'an' : 'a'} ${d.type} dialog "${String(d.message || '').slice(0, 120)}" and is frozen until it's answered: run browser_act with [{op:"dialog",accept:true|false}] (or answer it in the browser)`);
|
|
318
|
+
}
|
|
208
319
|
|
|
209
320
|
// If a driven tab is closed (by the user or by us), forget it everywhere so a session doesn't
|
|
210
321
|
// keep pointing at a dead tab.
|
|
211
322
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
212
323
|
attachedTabs.delete(tabId);
|
|
324
|
+
forgetTab(tabId, true);
|
|
213
325
|
const owner = tabOwner.get(tabId);
|
|
214
326
|
tabOwner.delete(tabId);
|
|
215
327
|
if (owner != null) {
|
|
@@ -303,35 +415,99 @@ async function endSession(session) {
|
|
|
303
415
|
* action re-resolves the exact element it was chosen from.
|
|
304
416
|
* -------------------------------------------------------------------------- */
|
|
305
417
|
|
|
306
|
-
|
|
418
|
+
// DOM activity tracker, kept apart from the ref cache (window.__pawbrowse) so creating it can't
|
|
419
|
+
// reset ref numbering. Installed by the first snapshot or wait in each document; shadow roots are
|
|
420
|
+
// added as the snapshot discovers them. Keeps recent mutation times so a wait can tell "this page
|
|
421
|
+
// is always animating" (don't wait for quiet that never comes) from "the action changed things".
|
|
422
|
+
const MO_INSTALL = `var M=window.__pawmo; if(!M){ M=window.__pawmo={last:performance.now(),times:[],roots:new WeakSet()};
|
|
423
|
+
M.mo=new MutationObserver(function(){ var t=performance.now(); M.last=t; M.times.push(t); if(M.times.length>64) M.times.shift(); });
|
|
424
|
+
M.watch=function(r){ if(!M.roots.has(r)){ M.roots.add(r); try{ M.mo.observe(r,{subtree:true,childList:true,attributes:true,characterData:true}); }catch(_){} } };
|
|
425
|
+
M.watch(document); }`;
|
|
426
|
+
const SNAPSHOT = `(function(seed, opts){
|
|
427
|
+
opts=opts||{};
|
|
307
428
|
try{
|
|
308
429
|
if(!document.body) return null;
|
|
309
|
-
|
|
310
|
-
|
|
430
|
+
// A NEW document continues numbering from where the tab's previous document stopped (seed), so a
|
|
431
|
+
// ref held over from the last page can never silently name a different element on this one.
|
|
432
|
+
var cache = window.__pawbrowse || (window.__pawbrowse = {ids:new WeakMap(), nodes:new Map(), next:Math.max(1,seed|0), byId:{}});
|
|
433
|
+
var fresh=new Set(); // node ids first allocated during this snapshot
|
|
434
|
+
function identity(e){
|
|
435
|
+
var id=cache.ids.get(e), holder=id!=null && cache.nodes.get(id);
|
|
436
|
+
// A new id if unseen — or if its old id now belongs to a different live element (a node that
|
|
437
|
+
// was detached, had its ref handed to a replacement, then came back).
|
|
438
|
+
if(id==null || (holder && holder!==e && holder.isConnected)){ id=cache.next++; cache.ids.set(e, id); fresh.add(id); }
|
|
439
|
+
cache.nodes.set(id,e); return id;
|
|
440
|
+
}
|
|
311
441
|
cache.nodes.forEach(function(e,id){ if(!e.isConnected) cache.nodes.delete(id); });
|
|
312
|
-
|
|
313
|
-
|
|
442
|
+
${MO_INSTALL}
|
|
443
|
+
// CLOSED shadow roots are invisible to page JS; the extension hands them to us via CDP
|
|
444
|
+
// (probeClosedRoots) keyed by host. sroot() = a host's shadow root, open or closed.
|
|
445
|
+
if(!cache.closed){ cache.closed=new WeakMap(); cache.probed=new WeakSet(); }
|
|
446
|
+
function sroot(n){ return n.shadowRoot || cache.closed.get(n) || null; }
|
|
447
|
+
cache.sroot=sroot;
|
|
448
|
+
var pendingHosts=[];
|
|
449
|
+
function safe(e){ return ['password','hidden'].indexOf(e.type)<0; }
|
|
450
|
+
// display:contents boxes (every <slot>, many design-system wrappers) have no box of their own, so
|
|
451
|
+
// checkVisibility() says false even though their children render: judge those by their parent.
|
|
452
|
+
function shown(e){ for(var g=0; e && g<32; g++){ if(e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return true; var v=e.ownerDocument.defaultView; if(!v || v.getComputedStyle(e).display!=='contents') return false; e=e.parentElement || (e.parentNode && e.parentNode.host); } return false; }
|
|
453
|
+
function visible(e){ return !!e && !e.closest('[aria-hidden="true"],[inert]') && shown(e); }
|
|
454
|
+
function sized(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; }
|
|
455
|
+
function clean(s,n){ return String(s||'').replace(/\\s+/g,' ').trim().slice(0,n||120); }
|
|
314
456
|
function name(e,seen){
|
|
315
457
|
seen=seen||new Set();
|
|
316
|
-
if(!e||seen.has(e)) return '';
|
|
458
|
+
if(!e||seen.has(e)||e.nodeType!==1) return '';
|
|
317
459
|
seen.add(e);
|
|
318
|
-
var
|
|
319
|
-
|
|
460
|
+
var tag=e.tagName;
|
|
461
|
+
if(tag==='SCRIPT'||tag==='STYLE'||tag==='NOSCRIPT'||tag==='TEMPLATE') return '';
|
|
462
|
+
if(tag.toLowerCase()==='svg'){ var st=e.querySelector('title'); return e.getAttribute('aria-label')||(st?st.textContent:''); }
|
|
463
|
+
var rt=(e.getRootNode&&e.getRootNode())||document; var gid=function(id){try{return (rt.getElementById&&rt.getElementById(id))||e.ownerDocument.getElementById(id);}catch(_){return null;}};
|
|
464
|
+
// (A control that lists ITSELF in aria-labelledby contributes its own content, per accname.)
|
|
465
|
+
var ref=(e.getAttribute('aria-labelledby')||'').split(/\\s+/).filter(Boolean).map(function(id){ var t=gid(id); return t===e ? clean(e.textContent,80) : name(t,seen); }).filter(Boolean).join(' ');
|
|
320
466
|
if(ref) return ref;
|
|
321
467
|
if(e.getAttribute('aria-label')) return e.getAttribute('aria-label');
|
|
322
468
|
var labs=[].slice.call(e.labels||[]).map(function(l){return name(l,seen);}).filter(Boolean).join(' ');
|
|
323
469
|
if(labs) return labs;
|
|
324
470
|
if(['button','submit','reset'].indexOf(e.type)>=0 && e.value) return e.value;
|
|
325
471
|
if(e.getAttribute('alt')) return e.getAttribute('alt');
|
|
326
|
-
|
|
472
|
+
// Visible descendants only: display:none / hidden children (tooltips, menus) must not leak in.
|
|
473
|
+
var txt = (tag==='INPUT'||tag==='SELECT'||tag==='TEXTAREA') ? '' : [].map.call(e.childNodes,function(n){ return n.nodeType===3 ? n.textContent : (n.nodeType===1 && visible(n) ? name(n,seen) : ''); }).join(' ').trim();
|
|
327
474
|
if(txt) return txt;
|
|
328
|
-
return e.getAttribute('title')||e.getAttribute('placeholder')||'';
|
|
475
|
+
return e.getAttribute('title')||e.getAttribute('placeholder')||e.getAttribute('aria-placeholder')||'';
|
|
476
|
+
}
|
|
477
|
+
// A form field with no programmatic label: use a nearby <label> sibling (the common unassociated
|
|
478
|
+
// "<label>Name</label><div><input></div>" markup), then placeholder-ish hints, then its name attr.
|
|
479
|
+
function fieldLabel(e){
|
|
480
|
+
var n=name(e); if(n) return n;
|
|
481
|
+
if(!e.isContentEditable && !e.matches('input,textarea,select')) return '';
|
|
482
|
+
for(var s=e.parentElement,d=0; s && d<3; s=s.parentElement,d++){
|
|
483
|
+
if(['BODY','HTML','FORM'].indexOf(s.tagName)>=0) break;
|
|
484
|
+
for(var k=0;k<s.children.length;k++){ var c=s.children[k]; if(c.tagName==='LABEL' && !c.contains(e) && !c.control){ var t=name(c); if(t) return t; } }
|
|
485
|
+
}
|
|
486
|
+
return e.getAttribute('data-placeholder')||e.getAttribute('name')||(e.isContentEditable?'Rich text editor':'');
|
|
487
|
+
}
|
|
488
|
+
// Last resort for a nameless control (icon-font buttons): a test id, a meaningful id/name, or an
|
|
489
|
+
// icon class (fa-trash, bi-share, icon-close, material-icons text...) — shown as "icon:trash".
|
|
490
|
+
function hint(e){
|
|
491
|
+
var t=e.getAttribute('data-testid')||e.getAttribute('data-test')||e.getAttribute('data-qa')||e.getAttribute('data-cy')||e.getAttribute('data-action');
|
|
492
|
+
if(t) return 'testid:'+clean(t,40);
|
|
493
|
+
var els=[e].concat([].slice.call(e.querySelectorAll('i,span,svg,use,img')).slice(0,6));
|
|
494
|
+
for(var k=0;k<els.length;k++){
|
|
495
|
+
var cls=(els[k].getAttribute('class')||'')+' '+(els[k].getAttribute('href')||els[k].getAttribute('xlink:href')||'');
|
|
496
|
+
var re=/(?:^|[\\s#])(?:fa|bi|mdi|icon|glyphicon|lucide|ti|ri|octicon|ico)[-_]([a-z][a-z0-9-]{1,30})/ig, m;
|
|
497
|
+
while((m=re.exec(cls))){ if(!/^(solid|regular|light|thin|duotone|sharp|brands|lg|sm|xs|xl|fw|[0-9]+x|spin|pulse|icon|button|btn|wrapper|container|inner)$/i.test(m[1])) return 'icon:'+m[1].toLowerCase(); }
|
|
498
|
+
}
|
|
499
|
+
var id=e.id||e.getAttribute('name')||'';
|
|
500
|
+
if(id && /[a-z]{3}/i.test(id) && !/\\d{3}|[0-9a-f]{8}|^(ember|react|radix|mui|headlessui|:r)/i.test(id)) return 'id:'+clean(id,40);
|
|
501
|
+
return '';
|
|
329
502
|
}
|
|
330
503
|
var roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio','menuitemcheckbox','option','gridcell','combobox','textbox','searchbox','spinbutton','slider','treeitem'];
|
|
331
504
|
// Semantic controls + custom clickables: any contenteditable, an inline onclick, or a
|
|
332
505
|
// keyboard-focusable [tabindex] (framework buttons — React-Native-Web Pressables, design-system
|
|
333
506
|
// divs — often expose only these). cursor:pointer clickables are added separately in collect().
|
|
334
|
-
var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[tabindex]:not([tabindex="-1"]),'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
|
|
507
|
+
var selector='a[href],button,input,textarea,select,summary,[contenteditable]:not([contenteditable="false"]),[onclick],[tabindex]:not([tabindex="-1"]),[draggable="true"],'+roles.map(function(r){return '[role="'+r+'"]';}).join(',');
|
|
508
|
+
// Inputs whose value is SET (not typed): typing into these is unreliable, so type() routes them
|
|
509
|
+
// through a value setter. The hint tells the agent the expected format.
|
|
510
|
+
var SETTABLE={date:'YYYY-MM-DD',time:'HH:MM','datetime-local':'YYYY-MM-DDTHH:MM',month:'YYYY-MM',week:'YYYY-Www',color:'#rrggbb',range:''};
|
|
335
511
|
function role(e){
|
|
336
512
|
var explicit=e.getAttribute('role');
|
|
337
513
|
if(roles.indexOf(explicit)>=0) return explicit;
|
|
@@ -341,27 +517,62 @@ const SNAPSHOT = `(function(){
|
|
|
341
517
|
if(e.tagName==='TEXTAREA'||e.isContentEditable) return 'textbox';
|
|
342
518
|
if(e.tagName==='INPUT'){
|
|
343
519
|
if(['checkbox','radio'].indexOf(e.type)>=0) return e.type;
|
|
344
|
-
if(['button','submit','reset','image'].indexOf(e.type)>=0) return 'button';
|
|
520
|
+
if(['button','submit','reset','image','file'].indexOf(e.type)>=0) return 'button';
|
|
345
521
|
if(e.type==='search') return 'searchbox';
|
|
346
522
|
if(e.type==='number') return 'spinbutton';
|
|
347
|
-
if(
|
|
523
|
+
if(e.type==='range') return 'slider';
|
|
524
|
+
if(['text','email','url','tel'].indexOf(e.type)>=0 || SETTABLE.hasOwnProperty(e.type)) return 'textbox';
|
|
348
525
|
}
|
|
349
526
|
return null;
|
|
350
527
|
}
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
528
|
+
// Hit-test a frame-local point in the element's own root, descending into nested open shadow
|
|
529
|
+
// roots, so a covered control (overlay, modal backdrop, pointer-events:none) is flagged.
|
|
530
|
+
cache.hits=function(t, lx, ly){
|
|
531
|
+
try{
|
|
532
|
+
if(lx==null){ var hr=t.getBoundingClientRect(); lx=hr.x+hr.width/2; ly=hr.y+hr.height/2; }
|
|
533
|
+
var root=t.getRootNode(); if(!root.elementFromPoint) root=t.ownerDocument;
|
|
534
|
+
var f=root.elementFromPoint(lx,ly), g=0;
|
|
535
|
+
while(f && sroot(f) && g++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
|
|
536
|
+
return !!f && (t===f || t.contains(f) || (t.control && t.control===f));
|
|
537
|
+
}catch(_){ return true; }
|
|
538
|
+
};
|
|
539
|
+
var hits=cache.hits;
|
|
540
|
+
// The element to click for a control. Styled checkboxes/radios/file pickers usually hide the
|
|
541
|
+
// native input (opacity:0, 0x0, display:none, sr-only) and show a <label> or a card instead:
|
|
542
|
+
// the visible label is the interaction surface; an opacity-0 input stretched over a visible
|
|
543
|
+
// card is clicked directly. Returns null when the control has no usable surface.
|
|
544
|
+
cache.surface=function(e){
|
|
545
|
+
if(!e||!e.isConnected) return null;
|
|
546
|
+
var hidable=e.tagName==='INPUT' && ['checkbox','radio','file'].indexOf(e.type)>=0;
|
|
547
|
+
if(visible(e) && sized(e)){
|
|
548
|
+
// A sr-only (1px, clipped) native input is "visible" but not clickable: use its label.
|
|
549
|
+
if(!hidable || cache.hits(e)) return e;
|
|
550
|
+
}
|
|
551
|
+
if(!hidable) return null;
|
|
552
|
+
if(e.closest('[aria-hidden="true"],[inert]')) return null;
|
|
553
|
+
var labs=[].slice.call(e.labels||[]);
|
|
554
|
+
for(var i=0;i<labs.length;i++) if(visible(labs[i]) && sized(labs[i])) return labs[i];
|
|
555
|
+
if(visible(e) && sized(e)) return e; // no label to fall back to: report it as-is (covered)
|
|
556
|
+
if(sized(e) && e.checkVisibility({checkOpacity:false,checkVisibilityCSS:true}) && visible(e.parentElement)){
|
|
557
|
+
try{ if(e.ownerDocument.defaultView.getComputedStyle(e).pointerEvents!=='none') return e; }catch(_){}
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
};
|
|
561
|
+
// Identity-focused semantic guard: role + accessible name. Catches a target silently becoming a
|
|
562
|
+
// different control (relabel), while tolerating value/checked/expanded churn.
|
|
563
|
+
cache.guard=function(el){ if(!el) return ''; try{ return [role(el),clean(fieldLabel(el)||hint(el),200)].join(String.fromCharCode(1)); }catch(_){ return ''; } };
|
|
564
|
+
var unnamed=function(g){ return !g || g.charAt(g.length-1)===String.fromCharCode(1); }; // role only, no label: never rebind on it
|
|
565
|
+
// Walk the top document, OPEN shadow roots, and SAME-ORIGIN iframes. dx/dy translate each
|
|
566
|
+
// element's frame-local rect into top-level viewport coordinates (shadow roots share their
|
|
567
|
+
// frame's coords; iframes add their content-box offset). Cross-origin frames can't be read from
|
|
568
|
+
// here; they're reported so the agent knows content exists that it can't see.
|
|
569
|
+
var frames=[], remoteEls=[], textRoots=[];
|
|
360
570
|
function collect(){
|
|
361
571
|
var out=[], seen=new Set(), scanned=0;
|
|
362
572
|
function add(el, dx, dy, clk){ if(seen.has(el)) return; seen.add(el); out.push({el:el, dx:dx, dy:dy, clk:clk}); }
|
|
363
573
|
function walk(root, dx, dy, depth){
|
|
364
574
|
if(depth>12) return;
|
|
575
|
+
textRoots.push({root:root, dx:dx, dy:dy});
|
|
365
576
|
var els; try{ els=root.querySelectorAll(selector); }catch(_){ els=[]; }
|
|
366
577
|
for(var a=0;a<els.length;a++) add(els[a], dx, dy, false);
|
|
367
578
|
var all; try{ all=root.querySelectorAll('*'); }catch(_){ all=[]; }
|
|
@@ -381,100 +592,266 @@ const SNAPSHOT = `(function(){
|
|
|
381
592
|
}
|
|
382
593
|
}catch(_){}
|
|
383
594
|
}
|
|
384
|
-
|
|
385
|
-
if(
|
|
386
|
-
|
|
595
|
+
var sr=sroot(n);
|
|
596
|
+
if(sr){ M.watch(sr); walk(sr, dx, dy, depth+1); }
|
|
597
|
+
else if(n.localName.indexOf('-')>0 && !cache.probed.has(n) && pendingHosts.length<40 && sized(n)) pendingHosts.push(n); // custom element: may hide a closed root
|
|
598
|
+
if(n.tagName==='IFRAME' || n.tagName==='FRAME'){
|
|
599
|
+
var idoc=null; try{ idoc=n.contentDocument; }catch(_){}
|
|
387
600
|
if(idoc && idoc.body){
|
|
388
|
-
var ir=n.getBoundingClientRect()
|
|
389
|
-
walk(idoc,
|
|
390
|
-
|
|
391
|
-
|
|
601
|
+
var ir=n.getBoundingClientRect();
|
|
602
|
+
walk(idoc, dx+ir.left+n.clientLeft+(parseFloat(n.ownerDocument.defaultView.getComputedStyle(n).paddingLeft)||0),
|
|
603
|
+
dy+ir.top+n.clientTop+(parseFloat(n.ownerDocument.defaultView.getComputedStyle(n).paddingTop)||0), depth+1);
|
|
604
|
+
} else if(visible(n) && (function(){ var fr=n.getBoundingClientRect(); return fr.width>=10 && fr.height>=10; })()){ // 1x1 ad/tracking frames don't count
|
|
605
|
+
var src=''; try{ src=new URL(n.src, location.href).host; }catch(_){ src=n.getAttribute('src')||''; }
|
|
606
|
+
frames.push(clean(n.getAttribute('title')||n.getAttribute('aria-label')||n.name||src||'frame',60));
|
|
607
|
+
remoteEls.push(n);
|
|
392
608
|
}
|
|
393
|
-
}
|
|
609
|
+
}
|
|
394
610
|
}
|
|
395
611
|
}
|
|
396
612
|
walk(document, 0, 0, 0);
|
|
397
613
|
return out;
|
|
398
614
|
}
|
|
399
|
-
var
|
|
615
|
+
var scrollerCache=new Map(); // element -> does it clip its overflow? (one style read per ancestor per snapshot)
|
|
616
|
+
function clipper(a, view){ var v=scrollerCache.get(a); if(v===undefined){ var cs=view.getComputedStyle(a); v=!(cs.overflowX==='visible' && cs.overflowY==='visible'); scrollerCache.set(a,v); } return v; }
|
|
617
|
+
function clipped(t){
|
|
618
|
+
try{
|
|
619
|
+
var r=t.getBoundingClientRect(), cx=r.x+r.width/2, cy=r.y+r.height/2, view=t.ownerDocument.defaultView;
|
|
620
|
+
for(var a=t.parentElement, g=0; a && g<40; a=a.parentElement, g++){
|
|
621
|
+
if(!clipper(a, view)) continue;
|
|
622
|
+
var ar=a.getBoundingClientRect();
|
|
623
|
+
// How far outside the box it is (0 = inside), so the nearest hidden rows are kept first.
|
|
624
|
+
if(cx<ar.left||cx>ar.right||cy<ar.top||cy>ar.bottom) return 1+Math.max(ar.left-cx,cx-ar.right,ar.top-cy,cy-ar.bottom,0);
|
|
625
|
+
}
|
|
626
|
+
}catch(_){}
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
var inView=[], offView=[], farOff=0, nodes=collect(), VH=innerHeight, VW=innerWidth;
|
|
400
630
|
for(var i=0;i<nodes.length;i++){
|
|
401
631
|
var e=nodes[i].el, ox=nodes[i].dx, oy=nodes[i].dy, clk=nodes[i].clk;
|
|
402
632
|
try{
|
|
403
|
-
if(!safe(e)
|
|
404
|
-
|
|
633
|
+
if(!safe(e)||e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) continue;
|
|
634
|
+
// Cheap early exit for controls screens away (huge pages have thousands): count, don't process.
|
|
635
|
+
var r0=e.getBoundingClientRect();
|
|
636
|
+
if(!opts.all && r0.width>0 && r0.height>0){ var y0=r0.y+r0.height/2+oy; if(y0<-VH||y0>=2*VH){ if(visible(e)) farOff++; continue; } }
|
|
637
|
+
var surf=cache.surface(e); if(!surf) continue;
|
|
638
|
+
var rname=role(e);
|
|
405
639
|
if(!rname){
|
|
406
640
|
// Role-less custom clickable (cursor:pointer, inline onclick, or focusable [tabindex]).
|
|
407
|
-
// Only accept it if it has a real label and isn't just a wrapper around an actual control
|
|
408
|
-
// so we don't flood the table with layout containers.
|
|
641
|
+
// Only accept it if it has a real label and isn't just a wrapper around an actual control
|
|
642
|
+
// (or a <label> standing in for one), so we don't flood the table with layout containers.
|
|
409
643
|
var ti=e.getAttribute('tabindex');
|
|
410
|
-
if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1')){
|
|
644
|
+
if(clk || e.hasAttribute('onclick') || (ti!==null && ti!=='-1') || e.getAttribute('draggable')==='true'){
|
|
645
|
+
if(e.tagName==='LABEL' && e.control) continue;
|
|
411
646
|
if(!(name(e)||'').trim() || e.querySelector(selector)) continue;
|
|
412
647
|
rname='button';
|
|
413
648
|
}
|
|
414
649
|
}
|
|
415
|
-
if(!rname
|
|
650
|
+
if(!rname) continue;
|
|
651
|
+
var r=surf.getBoundingClientRect(), lx=r.x+r.width/2, ly=r.y+r.height/2, x=lx+ox, y=ly+oy;
|
|
652
|
+
if(x<0||x>=VW) continue;
|
|
416
653
|
if(rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
417
|
-
var base={node:identity(e), role:rname, label:(
|
|
654
|
+
var base={node:identity(e), role:rname, label:clean(fieldLabel(e)||hint(e)||rname), x:Math.round(x), y:Math.round(y), w:Math.round(r.width), h:Math.round(r.height)};
|
|
418
655
|
var achecked=e.getAttribute('aria-checked');
|
|
419
656
|
if(['checkbox','radio'].indexOf(e.type)>=0) base.checked=!!e.checked;
|
|
420
657
|
else if(achecked!=null) base.checked=(achecked==='true');
|
|
421
658
|
var aexp=e.getAttribute('aria-expanded'); if(aexp!=null) base.expanded=(aexp==='true');
|
|
422
659
|
var asel=e.getAttribute('aria-selected'); if(asel!=null) base.selected=(asel==='true');
|
|
660
|
+
if(e.required || e.getAttribute('aria-required')==='true') base.required=true;
|
|
661
|
+
if(e.getAttribute('draggable')==='true') base.draggable=true;
|
|
662
|
+
// Only surface validation errors on fields the user (or agent) has put a value in, or that the
|
|
663
|
+
// page itself flags, so an untouched required form isn't a wall of warnings.
|
|
664
|
+
if(e.getAttribute('aria-invalid')==='true') base.invalid='invalid';
|
|
665
|
+
else if(e.validity && !e.validity.valid && e.value) base.invalid=clean(e.validationMessage,80)||'invalid';
|
|
666
|
+
var clip=clipped(surf);
|
|
667
|
+
if(clip){
|
|
668
|
+
base.off='scroll'; base.dist=clip; // inside an overflow box, scrolled out of it: acting scrolls it into view
|
|
669
|
+
} else if(y<0||y>=VH){
|
|
670
|
+
// Off-screen (scrolled away): keep the nearest ones so the agent knows they exist; acting on
|
|
671
|
+
// them scrolls them into view first.
|
|
672
|
+
if(!opts.all && (y<-VH/2||y>=1.5*VH)){ farOff++; continue; }
|
|
673
|
+
base.off=y<0?'up':'down'; base.dist=y<0?-y:y-VH;
|
|
674
|
+
} else if((function(){ var fv=surf.ownerDocument.defaultView; return fv!==window && (lx<0||ly<0||lx>=fv.innerWidth||ly>=fv.innerHeight); })()){
|
|
675
|
+
base.off='scroll'; // scrolled out of its (same-origin) iframe's viewport
|
|
676
|
+
} else if(!hits(surf, lx, ly)){
|
|
677
|
+
// Not hittable: either scrolled out of an overflow container (reachable — acting scrolls it
|
|
678
|
+
// in) or genuinely covered by an overlay/modal (needs dismissing first).
|
|
679
|
+
base.covered=true;
|
|
680
|
+
}
|
|
681
|
+
var list=base.off?offView:inView;
|
|
423
682
|
if(e.tagName==='SELECT'){
|
|
424
683
|
base.kind='select';
|
|
425
|
-
|
|
426
|
-
base.
|
|
427
|
-
|
|
684
|
+
if(e.multiple) base.fmt='multiple: select values:[...]';
|
|
685
|
+
base.value=clean([].map.call(e.selectedOptions,function(o){return o.label;}).join(', '),80);
|
|
686
|
+
base.options=[].filter.call(e.options,function(o){return !o.disabled && !(o.closest&&o.closest('optgroup[disabled]'));}).map(function(o){return clean(o.label,60);}).slice(0,40);
|
|
687
|
+
list.push(base);
|
|
688
|
+
} else if(e.tagName==='INPUT' && e.type==='file'){
|
|
689
|
+
base.kind='upload';
|
|
690
|
+
if(e.files && e.files.length) base.value=clean([].map.call(e.files,function(f){return f.name;}).join(', '),80);
|
|
691
|
+
if(e.accept) base.fmt=clean(e.accept,60);
|
|
692
|
+
list.push(base);
|
|
428
693
|
} else {
|
|
429
|
-
var
|
|
430
|
-
var
|
|
431
|
-
|
|
694
|
+
var settable=e.tagName==='INPUT' && SETTABLE.hasOwnProperty(e.type);
|
|
695
|
+
var editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' && (['textbox','searchbox','spinbutton'].indexOf(rname)>=0 || (rname==='slider' && e.tagName==='INPUT') || (rname==='combobox' && ['INPUT','TEXTAREA'].indexOf(e.tagName)>=0));
|
|
696
|
+
var value = (['checkbox','radio'].indexOf(e.type)>=0) ? '' : (('value' in e && e.tagName!=='BUTTON' && e.tagName!=='LI') ? String(e.value) : ((e.isContentEditable||rname==='combobox') ? e.innerText.trim() : ''));
|
|
697
|
+
if(e.tagName==='INPUT' && ['button','submit','reset','image'].indexOf(e.type)>=0) value=''; // its value IS its label
|
|
698
|
+
if(value) base.value=clean(value,80); // single line: page text must never forge extra table rows
|
|
699
|
+
if(settable){ base.fmt=e.type==='range' ? (e.min||'0')+'..'+(e.max||'100')+(e.step&&e.step!=='any'?' step '+e.step:'') : SETTABLE[e.type]; }
|
|
432
700
|
base.kind=editable?'fill':'click';
|
|
433
|
-
|
|
701
|
+
list.push(base);
|
|
434
702
|
// For an editable combobox, also offer a plain click to open its popup (not just type).
|
|
435
|
-
if(editable && rname==='combobox'){
|
|
703
|
+
if(editable && rname==='combobox'){ list.push({node:base.node, role:rname, label:'Open '+base.label, x:base.x, y:base.y, kind:'click', expanded:base.expanded, off:base.off, covered:base.covered}); }
|
|
436
704
|
}
|
|
437
705
|
}catch(_){ continue; }
|
|
438
706
|
}
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
|
|
707
|
+
var omitted=Math.max(0, inView.length-250); inView.splice(250);
|
|
708
|
+
// Keep the 25 off-screen controls NEAREST the visible area (not the first 25 in DOM order, which
|
|
709
|
+
// for a scrolled list are the rows furthest behind), then restore page order.
|
|
710
|
+
var OFFCAP=opts.all?400:25;
|
|
711
|
+
var offMore=Math.max(0, offView.length-OFFCAP)+farOff;
|
|
712
|
+
if(offView.length>OFFCAP){ offView.forEach(function(a,k){ a.ord=k; }); offView.sort(function(a,b){ return a.dist-b.dist; }); offView.splice(OFFCAP); offView.sort(function(a,b){ return a.ord-b.ord; }); }
|
|
713
|
+
var actions=inView.concat(offView);
|
|
714
|
+
// The nearest ancestor text that isn't just the label itself: which row/item a control is in.
|
|
715
|
+
function ctxOf(el, lab){
|
|
716
|
+
for(var p=el&&el.parentElement, g=0; p && g<6 && p.tagName!=='BODY'; p=p.parentElement, g++){
|
|
717
|
+
var tx=clean(p.innerText,400); if(!tx || tx===lab) continue;
|
|
718
|
+
var rest=clean(tx.split(lab).join(' '),40); if(rest) return rest;
|
|
719
|
+
}
|
|
720
|
+
return '';
|
|
721
|
+
}
|
|
722
|
+
// Identical labels ("Delete" per row, "Edit" per user) are ambiguous to the agent: tag each with
|
|
723
|
+
// the nearest ancestor text that tells them apart (e.g. the list row it lives in).
|
|
442
724
|
try{
|
|
443
|
-
var
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if(
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
725
|
+
var byLabel={};
|
|
726
|
+
for(var u=0;u<actions.length;u++){ var key=actions[u].kind+'|'+actions[u].label; (byLabel[key]=byLabel[key]||[]).push(actions[u]); }
|
|
727
|
+
Object.keys(byLabel).forEach(function(key){
|
|
728
|
+
var grp=byLabel[key]; if(grp.length<2) return;
|
|
729
|
+
grp.forEach(function(a){ var cx=ctxOf(cache.nodes.get(a.node), a.label); if(cx) a.ctx=cx; });
|
|
730
|
+
});
|
|
731
|
+
}catch(_){}
|
|
732
|
+
var focus=null;
|
|
733
|
+
try{ var fe=document.activeElement; while(fe && fe.shadowRoot && fe.shadowRoot.activeElement) fe=fe.shadowRoot.activeElement;
|
|
734
|
+
for(var q=0; fe && fe.tagName==='IFRAME' && q<8; q++){ var fd=null; try{ fd=fe.contentDocument; }catch(_){} fe=fd?fd.activeElement:null; }
|
|
735
|
+
if(fe && fe.tagName!=='BODY' && fe.tagName!=='HTML' && cache.ids.has(fe)) focus=cache.ids.get(fe);
|
|
736
|
+
}catch(_){}
|
|
453
737
|
// Displayed id derives from the STABLE node id (not position), so a reused number can never
|
|
454
738
|
// remap to a different element across observations; duplicates (e.g. combobox Open) get a suffix.
|
|
455
739
|
// Guarded so a getter/DOM quirk while building ids can't blank the whole table.
|
|
740
|
+
// Resolved to CDP frame ids by the extension (cross-origin frames): biggest first, so a real
|
|
741
|
+
// embedded app/checkout wins over banner slots when there are many.
|
|
742
|
+
cache.pendingHosts=pendingHosts;
|
|
743
|
+
cache.remoteEls=remoteEls.map(function(el){ var r=el.getBoundingClientRect(); return {el:el, a:r.width*r.height}; }).sort(function(p,q){ return q.a-p.a; }).slice(0,12).map(function(p){ return p.el; });
|
|
744
|
+
// Keep refs stable across re-renders that REPLACE nodes: a brand-new element whose identity
|
|
745
|
+
// (role + label + row context) uniquely matches an element that vanished since the last snapshot
|
|
746
|
+
// takes over its node id, so "e12" keeps meaning "Done in Task 7" and the agent's refs survive.
|
|
456
747
|
try{
|
|
457
|
-
|
|
458
|
-
|
|
748
|
+
var gone={}, gcount={}, ncount={}, k2;
|
|
749
|
+
Object.keys(cache.byId||{}).forEach(function(id){
|
|
750
|
+
var nid=cache.byId[id]; if(cache.nodes.has(nid)) return; // still on the page
|
|
751
|
+
var fp=cache.fps&&cache.fps[id]; if(!fp||!cache.guards[id]||unnamed(cache.guards[id])) return;
|
|
752
|
+
k2=cache.guards[id]+'\u0002'+(fp.ctx||''); gone[k2]=nid; gcount[k2]=(gcount[k2]||0)+1;
|
|
753
|
+
});
|
|
754
|
+
var keyOf=function(a){ return cache.guard(cache.nodes.get(a.node))+'\u0002'+(a.ctx||''); };
|
|
755
|
+
actions.forEach(function(a){ if(fresh.has(a.node)){ k2=keyOf(a); ncount[k2]=(ncount[k2]||0)+1; } });
|
|
756
|
+
actions.forEach(function(a){
|
|
757
|
+
if(!fresh.has(a.node)) return;
|
|
758
|
+
k2=keyOf(a);
|
|
759
|
+
if(gcount[k2]!==1 || ncount[k2]!==1) return; // ambiguous: never guess
|
|
760
|
+
var el=cache.nodes.get(a.node), old=gone[k2];
|
|
761
|
+
cache.nodes.delete(a.node); cache.ids.set(el, old); cache.nodes.set(old, el);
|
|
762
|
+
actions.forEach(function(b){ if(b.node===a.node && b!==a) b.node=old; });
|
|
763
|
+
a.node=old; delete gcount[k2];
|
|
764
|
+
});
|
|
459
765
|
}catch(_){}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
766
|
+
var focusId=null;
|
|
767
|
+
try{
|
|
768
|
+
cache.byId={}; cache.guards={}; cache.fps={}; var used={};
|
|
769
|
+
for(var j=0;j<actions.length;j++){ var bid='e'+actions[j].node, id=bid, kk=2; while(used[id]){ id=bid+'_'+kk; kk++; } used[id]=1; actions[j].id=id; cache.byId[id]=actions[j].node; cache.guards[id]=cache.guard(cache.nodes.get(actions[j].node)); cache.fps[id]={label:actions[j].label, ctx:actions[j].ctx||''}; if(focus!=null && actions[j].node===focus && !focusId) focusId=id; }
|
|
770
|
+
}catch(_){}
|
|
771
|
+
// Resolve a ref to its live element. Frameworks that re-render by REPLACING nodes (innerHTML
|
|
772
|
+
// templates, keyed lists) orphan every ref; re-find the replacement by the same identity the guard
|
|
773
|
+
// uses (role + label) plus its row context — only when exactly ONE element matches, never a guess.
|
|
774
|
+
cache.get=function(id){
|
|
775
|
+
var node=cache.byId[id]; if(node==null) return null;
|
|
776
|
+
var e=cache.nodes.get(node); if(e && e.isConnected) return e;
|
|
777
|
+
var fp=cache.fps && cache.fps[id], want=cache.guards && cache.guards[id]; if(!fp || !want || unnamed(want)) return null;
|
|
778
|
+
var cands=collect(), hit=null, n=0;
|
|
779
|
+
for(var k=0;k<cands.length && n<2;k++){
|
|
780
|
+
var el=cands[k].el;
|
|
781
|
+
try{
|
|
782
|
+
if(!el.isConnected || cache.guard(el)!==want || !cache.surface(el)) continue;
|
|
783
|
+
if(fp.ctx && ctxOf(el, fp.label)!==fp.ctx) continue;
|
|
784
|
+
hit=el; n++;
|
|
785
|
+
}catch(_){}
|
|
786
|
+
}
|
|
787
|
+
if(n!==1) return null;
|
|
788
|
+
cache.byId[id]=identity(hit);
|
|
789
|
+
return hit;
|
|
790
|
+
};
|
|
791
|
+
// Optional: the visible text in reading order (top-to-bottom, left-to-right), for prices, headings
|
|
792
|
+
// and results the controls table doesn't carry.
|
|
793
|
+
var vtext='';
|
|
794
|
+
if(opts.text){ try{
|
|
795
|
+
var frags=[], seenT=0;
|
|
796
|
+
textRoots.forEach(function(tr){
|
|
797
|
+
var doc=tr.root.ownerDocument||tr.root, w=doc.createTreeWalker(tr.root.body||tr.root, NodeFilter.SHOW_TEXT), rg=doc.createRange(), nd;
|
|
798
|
+
while((nd=w.nextNode()) && seenT<20000){ seenT++;
|
|
799
|
+
var v=nd.textContent.replace(/\\s+/g,' ').trim(), p=nd.parentElement;
|
|
800
|
+
if(!v||!p||p.closest('script,style,noscript,template')||!visible(p)) continue;
|
|
801
|
+
rg.selectNodeContents(nd); var q=rg.getBoundingClientRect(), ty=q.y+tr.dy, tx=q.x+tr.dx;
|
|
802
|
+
if(q.width<=0||q.height<=0||ty+q.height<0||ty>=VH||tx>=VW||tx+q.width<0) continue;
|
|
803
|
+
frags.push({t:v, x:tx, y:ty});
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
frags.sort(function(a,b){ return Math.round(a.y/6)-Math.round(b.y/6) || a.x-b.x; });
|
|
807
|
+
var lines=[], cur=null, lastY=-1e9;
|
|
808
|
+
frags.forEach(function(f){ if(Math.abs(f.y-lastY)>6){ if(cur) lines.push(cur); cur=f.t; lastY=f.y; } else cur+=' '+f.t; });
|
|
809
|
+
if(cur) lines.push(cur);
|
|
810
|
+
vtext=lines.join('\\n').slice(0,4000);
|
|
811
|
+
}catch(_){} }
|
|
812
|
+
return {url:location.href, title:document.title, vh:innerHeight, text:vtext, scrollY:Math.round(scrollY), scrollH:Math.round(document.documentElement.scrollHeight), omitted:omitted, offMore:offMore, frames:frames.slice(0,10), focus:focusId, next:cache.next, probe:pendingHosts.length, actions:actions};
|
|
813
|
+
}catch(_){ return {url:location.href, title:(document&&document.title)||'', scrollY:0, scrollH:0, omitted:0, actions:[]}; }
|
|
814
|
+
})`;
|
|
815
|
+
|
|
816
|
+
function formatRow(a) {
|
|
817
|
+
let flag = ' ';
|
|
818
|
+
if (typeof a.expanded === 'boolean') flag = a.expanded ? '▾' : '▸'; // open / closed
|
|
819
|
+
else if (typeof a.checked === 'boolean') flag = a.checked ? '✓' : '·';
|
|
820
|
+
else if (a.selected === true) flag = '◉';
|
|
821
|
+
let line = `${a.id.padEnd(4)} ${a.kind.padEnd(6)}${flag} "${a.label}"`;
|
|
822
|
+
if (a.ctx) line += ` in "${a.ctx}"`;
|
|
823
|
+
if (a.required) line += ' (required)';
|
|
824
|
+
if (a.value) line += ` ▸ "${a.value}"`;
|
|
825
|
+
if (a.fmt) line += ` fmt{${a.fmt}}`;
|
|
826
|
+
if (a.kind === 'select' && a.options && a.options.length) line += ` opts{${a.options.join(' | ')}}`;
|
|
827
|
+
if (a.invalid) line += ` ⚠ "${a.invalid}"`;
|
|
828
|
+
if (a.off === 'up') line += ' ↑ above view';
|
|
829
|
+
else if (a.off === 'down') line += ' ↓ below view';
|
|
830
|
+
else if (a.off === 'scroll') line += ' ↕ scrolled out of its box';
|
|
831
|
+
if (a.draggable) line += ' ⇄ draggable';
|
|
832
|
+
if (a.covered) line += ' ⊘ covered';
|
|
833
|
+
return line;
|
|
834
|
+
}
|
|
463
835
|
|
|
464
836
|
function formatTable(snap) {
|
|
465
837
|
if (!snap) return '(page not ready)';
|
|
466
838
|
const lines = [];
|
|
839
|
+
const fsnaps = snap.frameSnaps || [];
|
|
840
|
+
const count = snap.actions.length + fsnaps.reduce((n, f) => n + Math.min(60, f.snap.actions.length), 0);
|
|
467
841
|
lines.push(`${snap.title || '(untitled)'} — ${snap.url}`);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
842
|
+
const more = (snap.omitted || 0) + (snap.offMore || 0);
|
|
843
|
+
lines.push(`scroll ${snap.scrollY}/${snap.scrollH} · ${count} controls${more ? ` (+${more} more; scroll to reveal)` : ''}${snap.focus ? ` · focus ${snap.focus}` : ''}`);
|
|
844
|
+
if (snap.frames && snap.frames.length > fsnaps.length) lines.push(`cross-origin frames (content not readable): ${snap.frames.map((f) => `"${f}"`).join(', ')}`);
|
|
845
|
+
for (const a of snap.actions) lines.push(formatRow(a));
|
|
846
|
+
for (const { f, off, snap: fs } of fsnaps) {
|
|
847
|
+
let host = f.url;
|
|
848
|
+
try { host = new URL(f.url).host || f.url; } catch {}
|
|
849
|
+
lines.push(`frame f${f.idx} "${String(host).slice(0, 60)}" (cross-origin):`);
|
|
850
|
+
for (const a of fs.actions.slice(0, 60)) {
|
|
851
|
+
const ty = off.y + a.y;
|
|
852
|
+
if (!a.off && (ty < 0 || ty >= (snap.vh || 1e9))) a.off = ty < 0 ? 'up' : 'down';
|
|
853
|
+
lines.push(formatRow({ ...a, id: `f${f.idx}.${a.id}` }));
|
|
854
|
+
}
|
|
478
855
|
}
|
|
479
856
|
return lines.join('\n');
|
|
480
857
|
}
|
|
@@ -486,21 +863,260 @@ const SIG = `JSON.stringify([location.href, document.title, [].map.call(document
|
|
|
486
863
|
|
|
487
864
|
// Retry through transient "document is navigating" states so a snapshot taken
|
|
488
865
|
// during a transition settles instead of failing.
|
|
489
|
-
|
|
866
|
+
// Readable page text. innerText stops at shadow roots and iframes, so it misses web-component
|
|
867
|
+
// content and same-origin frames. Mark every composed ancestor of a shadow host / iframe; unmarked
|
|
868
|
+
// subtrees use the (fast, layout-aware) innerText, marked ones are walked through their composed
|
|
869
|
+
// children (shadow root, slots' assigned nodes, iframe document).
|
|
870
|
+
const READ_TEXT = `(function(max){
|
|
871
|
+
var main=document.querySelector('main')||document.body; if(!main) return {title:document.title,url:location.href,text:''};
|
|
872
|
+
var mark=new Set(), C=window.__pawbrowse&&window.__pawbrowse.closed;
|
|
873
|
+
function sr(n){ return n.shadowRoot || (C&&C.get(n)) || null; }
|
|
874
|
+
function scan(root,d){ if(d>12) return; var all; try{ all=root.querySelectorAll('*'); }catch(_){ return; }
|
|
875
|
+
for(var i=0;i<all.length;i++){ var n=all[i], special=false;
|
|
876
|
+
if(sr(n)){ special=true; scan(sr(n),d+1); }
|
|
877
|
+
if(n.tagName==='SLOT') special=true;
|
|
878
|
+
if(n.tagName==='IFRAME'||n.tagName==='FRAME'){ var doc=null; try{ doc=n.contentDocument; }catch(_){} if(doc&&doc.body){ special=true; scan(doc,d+1); } }
|
|
879
|
+
if(special){ for(var p=n; p && !mark.has(p); ){ mark.add(p); p=p.parentNode; if(p && p.nodeType===11) p=p.host; } }
|
|
880
|
+
} }
|
|
881
|
+
scan(document,0);
|
|
882
|
+
var out=[], len=0;
|
|
883
|
+
function vis(e){ try{ if(e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return true; return getComputedStyle(e).display==='contents'; }catch(_){ return true; } }
|
|
884
|
+
function put(t){ if(t && len<max){ out.push(t); len+=t.length; } }
|
|
885
|
+
function kids(n){
|
|
886
|
+
if(sr(n)) return sr(n).childNodes;
|
|
887
|
+
if(n.tagName==='SLOT'){ var a=n.assignedNodes({flatten:true}); return a.length?a:n.childNodes; }
|
|
888
|
+
return n.childNodes;
|
|
889
|
+
}
|
|
890
|
+
function walk(n,d){
|
|
891
|
+
if(len>=max||d>60) return;
|
|
892
|
+
if(n.nodeType===3){ var v=n.textContent.replace(/\\s+/g,' ').trim(); if(v && n.parentElement && vis(n.parentElement)) put(v); return; }
|
|
893
|
+
if(n.nodeType!==1 && n.nodeType!==11) return;
|
|
894
|
+
if(n.nodeType===1){
|
|
895
|
+
if(/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(n.tagName) || !vis(n)) return;
|
|
896
|
+
if(n.tagName==='IFRAME'||n.tagName==='FRAME'){ var doc=null; try{ doc=n.contentDocument; }catch(_){} if(doc&&doc.body){ put('\\n'); walk(doc.body,d+1); put('\\n'); } return; }
|
|
897
|
+
if(!mark.has(n)){ put(n.innerText); if(getComputedStyle(n).display!=='inline') put('\\n'); return; }
|
|
898
|
+
}
|
|
899
|
+
var k=kids(n); for(var i=0;i<k.length;i++) walk(k[i],d+1);
|
|
900
|
+
if(n.nodeType===1 && getComputedStyle(n).display!=='inline') put('\\n');
|
|
901
|
+
}
|
|
902
|
+
walk(main,0);
|
|
903
|
+
var text=out.join(' ').replace(/[ \\t]*\\n[ \\t]*/g,'\\n').replace(/\\n{3,}/g,'\\n\\n').trim().slice(0,max);
|
|
904
|
+
return {title:document.title,url:location.href,text:text};
|
|
905
|
+
})`;
|
|
906
|
+
|
|
907
|
+
/* ------------------------------ Cross-origin frames ------------------------- *
|
|
908
|
+
* The page-side snapshot can't reach into cross-origin iframes (payment fields, embedded logins,
|
|
909
|
+
* widgets). CDP can: cross-SITE frames are separate targets we auto-attach to as flat child
|
|
910
|
+
* sessions; cross-origin but same-site frames share their parent's process and get their own
|
|
911
|
+
* isolated world via frameId. Each readable frame gets a stable index per tab (f1, f2...), its
|
|
912
|
+
* refs are shown as f1.e3, and clicks are translated by the frame's on-screen offset. */
|
|
913
|
+
const AUTO_ATTACH = { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, filter: [{ type: 'iframe' }, { exclude: true }] };
|
|
914
|
+
const childSessions = new Map(); // tabId -> Map(sessionId -> { targetId, parent: sessionId|null })
|
|
915
|
+
const frameIdx = new Map(); // tabId -> { next, byKey: Map(key -> idx), byIdx: Map(idx -> frame) }
|
|
916
|
+
function kidsOf(tabId) { let m = childSessions.get(tabId); if (!m) { m = new Map(); childSessions.set(tabId, m); } return m; }
|
|
917
|
+
|
|
918
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
919
|
+
const tabId = source.tabId;
|
|
920
|
+
if (tabId == null) return;
|
|
921
|
+
if (method === 'Target.attachedToTarget') {
|
|
922
|
+
const info = params.targetInfo || {};
|
|
923
|
+
if (info.type !== 'iframe' || /^chrome-extension:/i.test(info.url || '')) return; // other extensions' frames aren't ours to drive
|
|
924
|
+
// Ad-heavy pages spawn hundreds of (mostly invisible) frames: just record them; a session is
|
|
925
|
+
// only set up (ensureSession) when one of its frames is actually visible and read.
|
|
926
|
+
kidsOf(tabId).set(params.sessionId, { targetId: info.targetId, parent: source.sessionId || null, ready: null });
|
|
927
|
+
} else if (method === 'Target.detachedFromTarget') {
|
|
928
|
+
kidsOf(tabId).delete(params.sessionId);
|
|
929
|
+
const m = worlds.get(tabId);
|
|
930
|
+
if (m) for (const k of [...m.keys()]) if (k.startsWith(`${params.sessionId}|`)) m.delete(k);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
async function ensureSession(tabId, sessionId) {
|
|
935
|
+
const k = kidsOf(tabId).get(sessionId);
|
|
936
|
+
if (!k) return;
|
|
937
|
+
if (!k.ready) {
|
|
938
|
+
const t = { tabId, sessionId };
|
|
939
|
+
k.ready = Promise.all([['Page.enable', {}], ['DOM.enable', {}], ['Target.setAutoAttach', AUTO_ATTACH]].map(([m, p]) => sendCdp(t, m, p).catch(() => {})));
|
|
940
|
+
}
|
|
941
|
+
await k.ready;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// CDP frame ids of the visible cross-origin iframes a snapshot found in `target`'s document.
|
|
945
|
+
async function remoteFrameIds(target) {
|
|
946
|
+
const ctx = await worldFor(target);
|
|
947
|
+
const r = await sendCdp(target, 'Runtime.evaluate', { expression: 'window.__pawbrowse && window.__pawbrowse.remoteEls', contextId: ctx, objectGroup: 'pawframes' });
|
|
948
|
+
const ids = [];
|
|
949
|
+
try {
|
|
950
|
+
if (!r.result || !r.result.objectId) return ids;
|
|
951
|
+
const { result } = await sendCdp(target, 'Runtime.getProperties', { objectId: r.result.objectId, ownProperties: true });
|
|
952
|
+
for (const p of result) {
|
|
953
|
+
if (!/^\d+$/.test(p.name) || !p.value || !p.value.objectId) continue;
|
|
954
|
+
try { const { node } = await sendCdp(target, 'DOM.describeNode', { objectId: p.value.objectId }); if (node.frameId) ids.push(node.frameId); } catch {}
|
|
955
|
+
}
|
|
956
|
+
} finally { sendCdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'pawframes' }).catch(() => {}); }
|
|
957
|
+
return ids;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Read the cross-origin frames under a snapshot, depth-first: only visible ones, never invisible
|
|
961
|
+
// subtrees. Cross-SITE frames are their own attached session; cross-origin same-site frames live
|
|
962
|
+
// in the parent's process and are addressed by frameId.
|
|
963
|
+
async function readFrames(tabId, parentTarget, parentSnap, out, depth = 0) {
|
|
964
|
+
if (!parentSnap || !parentSnap.frames || !parentSnap.frames.length || depth > 4 || out.length >= 12) return out;
|
|
965
|
+
let ids = [];
|
|
966
|
+
try { ids = await remoteFrameIds(parentTarget); } catch {}
|
|
967
|
+
let fi = frameIdx.get(tabId);
|
|
968
|
+
if (!fi) { fi = { next: 1, byKey: new Map(), byIdx: new Map() }; frameIdx.set(tabId, fi); }
|
|
969
|
+
for (const fid of ids) {
|
|
970
|
+
if (out.length >= 12) break;
|
|
971
|
+
const sid = [...kidsOf(tabId)].reverse().find(([, k]) => k.targetId === fid)?.[0]; // newest wins after a re-attach
|
|
972
|
+
const parentSession = typeof parentTarget === 'object' ? parentTarget.sessionId : undefined;
|
|
973
|
+
const f = sid
|
|
974
|
+
? { target: { tabId, sessionId: sid }, root: true, key: `s:${fid}` }
|
|
975
|
+
: { target: { tabId, sessionId: parentSession, frameId: fid }, root: false, key: `f:${fid}` };
|
|
976
|
+
try {
|
|
977
|
+
if (sid) await ensureSession(tabId, sid);
|
|
978
|
+
const off = await frameOffset(tabId, f);
|
|
979
|
+
if (off.w < 10 || off.h < 10) continue; // tracking pixels / collapsed frames
|
|
980
|
+
const fs = await snapshot(f.target, 1);
|
|
981
|
+
if (!fs || !fs.actions) continue;
|
|
982
|
+
if (!fi.byKey.has(f.key)) fi.byKey.set(f.key, fi.next++);
|
|
983
|
+
f.idx = fi.byKey.get(f.key); f.url = fs.url;
|
|
984
|
+
fi.byIdx.set(f.idx, f);
|
|
985
|
+
out.push({ f, off, snap: fs });
|
|
986
|
+
await readFrames(tabId, f.target, fs, out, depth + 1);
|
|
987
|
+
} catch {}
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Top-level viewport offset (and size) of a frame's content box: its owner <iframe>'s content quad
|
|
993
|
+
// in the parent's local root, plus that parent session's own offset, up to the top.
|
|
994
|
+
async function frameOffset(tabId, f) {
|
|
995
|
+
let x = 0, y = 0, w = 0, h = 0, first = true, topOwner = null;
|
|
996
|
+
let sess = f.target.sessionId || null, isRoot = !!f.root;
|
|
997
|
+
let fid = isRoot ? kidsOf(tabId).get(sess)?.targetId : f.target.frameId;
|
|
998
|
+
for (let g = 0; g < 10 && fid; g++) {
|
|
999
|
+
// A session's root frame is owned by an <iframe> in the PARENT session; an in-process frame's
|
|
1000
|
+
// owner is in its own session, whose box coords are relative to that session's root.
|
|
1001
|
+
const owner = isRoot ? (kidsOf(tabId).get(sess)?.parent ?? null) : sess;
|
|
1002
|
+
const t = owner ? { tabId, sessionId: owner } : tabId;
|
|
1003
|
+
const { backendNodeId } = await sendCdp(t, 'DOM.getFrameOwner', { frameId: fid });
|
|
1004
|
+
const { model } = await sendCdp(t, 'DOM.getBoxModel', { backendNodeId });
|
|
1005
|
+
const q = model.content;
|
|
1006
|
+
x += q[0]; y += q[1];
|
|
1007
|
+
if (first) { w = q[2] - q[0]; h = q[5] - q[1]; first = false; }
|
|
1008
|
+
if (!owner) { topOwner = backendNodeId; break; } // reached the top session: coordinates are now top-level
|
|
1009
|
+
sess = owner; isRoot = true; fid = kidsOf(tabId).get(owner)?.targetId;
|
|
1010
|
+
}
|
|
1011
|
+
return { x, y, w, h, topOwner };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Split "f2.e7" into its frame and the frame-local ref.
|
|
1015
|
+
function routeRef(tabId, ref) {
|
|
1016
|
+
const m = /^f(\d+)\.(.+)$/.exec(String(ref || ''));
|
|
1017
|
+
if (!m) return { target: tabId, ref, frame: null };
|
|
1018
|
+
const f = frameIdx.get(tabId)?.byIdx.get(Number(m[1]));
|
|
1019
|
+
if (!f) return { target: null, ref: m[2], frame: null };
|
|
1020
|
+
return { target: f.target, ref: m[2], frame: f };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Hand CLOSED shadow roots of the custom elements a snapshot flagged to our isolated world:
|
|
1024
|
+
// DOM.describeNode(pierce) exposes them to CDP even though page JS can't reach them. Each host is
|
|
1025
|
+
// probed once per document. Returns how many roots were found.
|
|
1026
|
+
async function probeClosedRoots(target) {
|
|
1027
|
+
const ctx = await worldFor(target);
|
|
1028
|
+
let found = 0;
|
|
1029
|
+
try {
|
|
1030
|
+
const r = await sendCdp(target, 'Runtime.evaluate', { expression: 'window.__pawbrowse && window.__pawbrowse.pendingHosts', contextId: ctx, objectGroup: 'pawshadow' });
|
|
1031
|
+
if (!r.result || !r.result.objectId) return 0;
|
|
1032
|
+
const { result } = await sendCdp(target, 'Runtime.getProperties', { objectId: r.result.objectId, ownProperties: true });
|
|
1033
|
+
for (const p of result) {
|
|
1034
|
+
if (!/^\d+$/.test(p.name) || !p.value || !p.value.objectId) continue;
|
|
1035
|
+
try {
|
|
1036
|
+
const { node } = await sendCdp(target, 'DOM.describeNode', { objectId: p.value.objectId, depth: 0, pierce: true });
|
|
1037
|
+
const root = (node.shadowRoots || []).find((x) => x.shadowRootType === 'closed');
|
|
1038
|
+
if (!root) continue;
|
|
1039
|
+
const { object } = await sendCdp(target, 'DOM.resolveNode', { backendNodeId: root.backendNodeId, executionContextId: ctx, objectGroup: 'pawshadow' });
|
|
1040
|
+
await sendCdp(target, 'Runtime.callFunctionOn', { objectId: p.value.objectId, functionDeclaration: 'function(r){ window.__pawbrowse.closed.set(this, r); }', arguments: [{ objectId: object.objectId }] });
|
|
1041
|
+
found++;
|
|
1042
|
+
} catch {}
|
|
1043
|
+
}
|
|
1044
|
+
await sendCdp(target, 'Runtime.evaluate', { expression: 'window.__pawbrowse.pendingHosts.forEach(function(h){ window.__pawbrowse.probed.add(h); })', contextId: ctx });
|
|
1045
|
+
} finally { sendCdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'pawshadow' }).catch(() => {}); }
|
|
1046
|
+
return found;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function snapshot(target, tries = 8, opts) {
|
|
1050
|
+
const O = JSON.stringify(opts || {});
|
|
1051
|
+
const sk = typeof target === 'object' ? `${target.tabId}#${frameKey(target)}` : target;
|
|
490
1052
|
let last;
|
|
491
|
-
for (let i = 0; i <
|
|
1053
|
+
for (let i = 0; i < tries; i++) {
|
|
492
1054
|
try {
|
|
493
|
-
|
|
494
|
-
|
|
1055
|
+
let snap = await evaluate(target, `${SNAPSHOT}(${refSeed.get(sk) || 1}, ${O})`);
|
|
1056
|
+
// Closed shadow roots found: re-snapshot with them (nested closed hosts: a few rounds).
|
|
1057
|
+
for (let k = 0; k < 3 && snap && snap.probe; k++) {
|
|
1058
|
+
if (!(await probeClosedRoots(target).catch(() => 0))) break;
|
|
1059
|
+
snap = await evaluate(target, `${SNAPSHOT}(${refSeed.get(sk) || 1}, ${O})`);
|
|
1060
|
+
}
|
|
1061
|
+
if (snap) { if (snap.next > (refSeed.get(sk) || 1)) { refSeed.set(sk, snap.next); persistState(); } return snap; }
|
|
495
1062
|
} catch (e) { last = e; }
|
|
496
|
-
await sleep(120);
|
|
1063
|
+
if (i < tries - 1) await sleep(120);
|
|
497
1064
|
}
|
|
498
1065
|
if (last) throw last;
|
|
499
1066
|
return null;
|
|
500
1067
|
}
|
|
501
1068
|
|
|
502
|
-
|
|
503
|
-
|
|
1069
|
+
// The last table each tab reported, so act() can tell the agent whether ANYTHING it can see changed
|
|
1070
|
+
// (scrolling a panel, opening a popover...) rather than only form values / URL / control count.
|
|
1071
|
+
const lastTable = new Map(); // tabId -> table text (without the header lines that hold ref numbers)
|
|
1072
|
+
|
|
1073
|
+
// Ref numbers and the focus marker are dropped: clicking a button that does nothing still focuses
|
|
1074
|
+
// it, and that alone must not count as "the page changed".
|
|
1075
|
+
function tableBody(t) { return String(t).split('\n').map((l) => l.replace(/^(f\d+\.)?e\d+(_\d+)?\s+/, '').replace(/ {2}· {2}focus e\S+$/, '')).join('\n'); }
|
|
1076
|
+
|
|
1077
|
+
// What the agent last saw, per tab (full table text), so act() can answer with just the rows that
|
|
1078
|
+
// changed. Same document + mostly-unchanged table => delta; otherwise the full table.
|
|
1079
|
+
const lastFull = new Map();
|
|
1080
|
+
const ROW_ID = /^((?:f\d+\.)?e\d+(?:_\d+)?)\s/;
|
|
1081
|
+
function deltaTable(prev, cur) {
|
|
1082
|
+
if (!prev) return null;
|
|
1083
|
+
const P = prev.split('\n'), C = cur.split('\n');
|
|
1084
|
+
if (P[0] !== C[0]) return null; // title/url changed: a different page, send it whole
|
|
1085
|
+
const prevRows = new Map();
|
|
1086
|
+
for (const l of P) { const m = ROW_ID.exec(l); if (m) prevRows.set(m[1], l); }
|
|
1087
|
+
const out = [], ids = new Set();
|
|
1088
|
+
let rows = 0, unchanged = 0;
|
|
1089
|
+
for (const l of C) {
|
|
1090
|
+
const m = ROW_ID.exec(l);
|
|
1091
|
+
if (!m) { out.push(l); continue; } // headers, frame and tool lines: always
|
|
1092
|
+
rows++; ids.add(m[1]);
|
|
1093
|
+
if (prevRows.get(m[1]) === l) unchanged++; else out.push(l);
|
|
1094
|
+
}
|
|
1095
|
+
if (rows < 12 || unchanged < rows * 0.5) return null; // small table or big change: whole is clearer
|
|
1096
|
+
const gone = [...prevRows.keys()].filter((id) => !ids.has(id));
|
|
1097
|
+
const at = out.findIndex((l) => l.startsWith('scroll ')) + 1 || 1;
|
|
1098
|
+
out.splice(at, 0, `(only changes shown: ${rows - unchanged} new/changed row(s); ${unchanged} unchanged row(s) omitted${gone.length ? `; gone: ${gone.slice(0, 40).join(', ')}${gone.length > 40 ? ' …' : ''}` : ''} — browser_observe for the full table)`);
|
|
1099
|
+
return out.join('\n');
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function observe(tabId, opts) {
|
|
1103
|
+
const find = opts && opts.find != null && String(opts.find).trim() ? String(opts.find).trim().toLowerCase() : null;
|
|
1104
|
+
const snap = await snapshot(tabId, 8, { all: !!find, text: !!(opts && opts.text) });
|
|
1105
|
+
if (snap) snap.frameSnaps = await readFrames(tabId, tabId, snap, []);
|
|
1106
|
+
if (find && snap) {
|
|
1107
|
+
// Whole-page search: only rows whose label / row context / value mention the query.
|
|
1108
|
+
const hit = (a) => [a.label, a.ctx, a.value].some((v) => v && String(v).toLowerCase().includes(find));
|
|
1109
|
+
const total = snap.actions.length;
|
|
1110
|
+
snap.actions = snap.actions.filter(hit).slice(0, 80);
|
|
1111
|
+
for (const f of snap.frameSnaps) f.snap.actions = f.snap.actions.filter(hit);
|
|
1112
|
+
snap.omitted = 0; snap.offMore = 0;
|
|
1113
|
+
snap.findNote = `(find "${opts.find}": ${snap.actions.length} of ${total} controls on the whole page match; browser_observe without find for the full table)`;
|
|
1114
|
+
}
|
|
1115
|
+
let t = formatTable(snap).replace('\n', `\n${formatTools(tabId)}`.replace(/\n$/, '') + '\n').replace(/\n\n/, '\n');
|
|
1116
|
+
if (snap && snap.findNote) t = t.replace(/\n/, `\n${snap.findNote}\n`);
|
|
1117
|
+
if (snap && snap.text) t += `\n\nvisible text (untrusted page content):\n${snap.text}`;
|
|
1118
|
+
if (!find) { lastTable.set(tabId, tableBody(t)); lastFull.set(tabId, t); }
|
|
1119
|
+
return t;
|
|
504
1120
|
}
|
|
505
1121
|
|
|
506
1122
|
/* -------------------------------- Actions --------------------------------- */
|
|
@@ -509,40 +1125,91 @@ async function observe(tabId) {
|
|
|
509
1125
|
// (elementFromPoint containment) so we never click a stale/covered/wrong target.
|
|
510
1126
|
async function resolveHit(tabId, ref, opts) {
|
|
511
1127
|
const forFill = opts && opts.fill ? 'true' : 'false';
|
|
1128
|
+
const noScroll = opts && opts.noScroll ? 'true' : 'false', noHit = opts && opts.noHit ? 'true' : 'false';
|
|
512
1129
|
const R = JSON.stringify(String(ref));
|
|
513
1130
|
return evaluate(tabId, `(function(){
|
|
514
1131
|
var c=window.__pawbrowse; if(!c||!c.byId) return {error:'no snapshot yet; observe first'};
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
var e=c.nodes.get(node);
|
|
1132
|
+
if(c.byId[${R}]==null) return {error:'unknown ref (observe again)'};
|
|
1133
|
+
var e=c.get?c.get(${R}):null;
|
|
518
1134
|
if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
|
|
519
1135
|
if(c.guard && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return {error:'element changed since observe (observe again)'};
|
|
520
1136
|
if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return {error:'element is disabled'};
|
|
521
1137
|
if(${forFill} && (e.readOnly||e.getAttribute('aria-readonly')==='true')) return {error:'field is read-only'};
|
|
522
1138
|
if(${forFill} && !('value' in e) && !e.isContentEditable) return {error:'not an editable field (observe again)'};
|
|
523
|
-
|
|
524
|
-
e.
|
|
525
|
-
|
|
1139
|
+
// Value-set inputs (date/time/range/color...) take the setter path, not click+type.
|
|
1140
|
+
if(${forFill} && e.tagName==='INPUT' && ['date','time','datetime-local','month','week','color','range'].indexOf(e.type)>=0) return {set:true};
|
|
1141
|
+
// Click the control's visible SURFACE: a styled checkbox's <label>, or the element itself.
|
|
1142
|
+
var s=c.surface?c.surface(e):e;
|
|
1143
|
+
if(!s) return {error:'element not visible'};
|
|
1144
|
+
if(!${noScroll}) s.scrollIntoView({block:'center',inline:'center'});
|
|
1145
|
+
var r=s.getBoundingClientRect(); if(!r.width||!r.height) return {error:'element has no size'};
|
|
526
1146
|
// Frame-local center (in the element's own frame viewport)...
|
|
527
1147
|
var lx=r.x+r.width/2, ly=r.y+r.height/2;
|
|
528
1148
|
// ...plus the offset chain of any ancestor iframes, giving the TOP-LEVEL click point for CDP.
|
|
529
|
-
var dx=0, dy=0, w=(
|
|
1149
|
+
var dx=0, dy=0, w=(s.ownerDocument&&s.ownerDocument.defaultView), g=0;
|
|
530
1150
|
while(w && w.frameElement && g++<12){
|
|
531
1151
|
var fe=w.frameElement, fr=fe.getBoundingClientRect(), fcs=fe.ownerDocument.defaultView.getComputedStyle(fe);
|
|
532
|
-
dx+=fr.left+
|
|
533
|
-
dy+=fr.top+
|
|
1152
|
+
dx+=fr.left+fe.clientLeft+(parseFloat(fcs.paddingLeft)||0);
|
|
1153
|
+
dy+=fr.top+fe.clientTop+(parseFloat(fcs.paddingTop)||0);
|
|
534
1154
|
w=fe.ownerDocument.defaultView;
|
|
535
1155
|
}
|
|
536
1156
|
var x=Math.round(lx+dx), y=Math.round(ly+dy);
|
|
537
1157
|
if(x<0||y<0||x>=innerWidth||y>=innerHeight) return {error:'element off-screen after scroll'};
|
|
538
|
-
// Hit-test in the
|
|
539
|
-
// coords, so shadow-DOM and iframe elements
|
|
540
|
-
|
|
541
|
-
if(
|
|
1158
|
+
// Hit-test in the surface's OWN root (document / shadow root / iframe doc) with frame-local
|
|
1159
|
+
// coords, descending through nested open shadow roots, so shadow-DOM and iframe elements
|
|
1160
|
+
// aren't falsely reported as covered.
|
|
1161
|
+
if(${noHit}) return {x:x, y:y};
|
|
1162
|
+
var root=s.getRootNode(); if(!root||!root.elementFromPoint) root=s.ownerDocument;
|
|
1163
|
+
var f=root.elementFromPoint(lx,ly), k=0;
|
|
1164
|
+
var sroot=function(n){ return n.shadowRoot || (c.closed && c.closed.get(n)) || null; };
|
|
1165
|
+
while(f && sroot(f) && k++<16){ var inner=sroot(f).elementFromPoint(lx,ly); if(!inner||inner===f) break; f=inner; }
|
|
1166
|
+
if(!f || !(s===f || s.contains(f) || (s.control && s.control===f))) return {error:'element is covered by another element (dismiss the overlay/dialog first)'};
|
|
542
1167
|
return {x:x, y:y};
|
|
543
1168
|
})()`);
|
|
544
1169
|
}
|
|
545
1170
|
|
|
1171
|
+
// Set the value of a date/time/month/week/color/range input the way a user's picker would: via the
|
|
1172
|
+
// native value setter (so framework value-trackers see a real change), then input + change events.
|
|
1173
|
+
// The browser sanitizes invalid values to '' (or clamps ranges), so we read back and report that.
|
|
1174
|
+
async function setValue(tabId, ref, text) {
|
|
1175
|
+
const R = JSON.stringify(String(ref));
|
|
1176
|
+
const V = JSON.stringify(String(text ?? ''));
|
|
1177
|
+
return evaluate(tabId, `(function(){
|
|
1178
|
+
var c=window.__pawbrowse; var e=c&&c.get&&c.get(${R});
|
|
1179
|
+
if(!e||!e.isConnected) return {error:'element no longer on page (observe again)'};
|
|
1180
|
+
var val=${V};
|
|
1181
|
+
var setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
|
|
1182
|
+
try{ e.focus({preventScroll:true}); }catch(_){}
|
|
1183
|
+
setter.call(e,val);
|
|
1184
|
+
e.dispatchEvent(new Event('input',{bubbles:true,composed:true}));
|
|
1185
|
+
e.dispatchEvent(new Event('change',{bubbles:true}));
|
|
1186
|
+
if(val!=='' && e.value==='') return {error:'value "'+val+'" rejected by the '+e.type+' field (use the fmt{} format shown)'};
|
|
1187
|
+
return {value:e.value};
|
|
1188
|
+
})()`);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Attach local files to an <input type=file> (DOM.setFileInputFiles). Chrome only allows this for
|
|
1192
|
+
// an extension that the user has granted "Allow access to file URLs" in chrome://extensions.
|
|
1193
|
+
async function uploadFiles(tabId, ref, paths) {
|
|
1194
|
+
const R = JSON.stringify(String(ref));
|
|
1195
|
+
const h = await evaluate(tabId, `(function(){
|
|
1196
|
+
var c=window.__pawbrowse; var e=c&&c.get&&c.get(${R});
|
|
1197
|
+
if(e && c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return null; // relabelled / reused input
|
|
1198
|
+
return (e&&e.isConnected&&e.tagName==='INPUT'&&e.type==='file'&&!e.disabled)?e:null;
|
|
1199
|
+
})()`, { handle: true });
|
|
1200
|
+
if (!h || !h.objectId) return { error: 'not a file-upload field, or it changed since observe (observe again)' };
|
|
1201
|
+
try {
|
|
1202
|
+
await sendCdp(tabId, 'DOM.setFileInputFiles', { files: paths, objectId: h.objectId }); // tabId: the frame target
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
return { error: /not allowed/i.test(e.message)
|
|
1205
|
+
? 'Chrome blocked the upload: enable "Allow access to file URLs" for PawBrowse in chrome://extensions'
|
|
1206
|
+
: e.message };
|
|
1207
|
+
} finally {
|
|
1208
|
+
sendCdp(tabId, 'Runtime.releaseObject', { objectId: h.objectId }).catch(() => {}); // (frame target, see above)
|
|
1209
|
+
}
|
|
1210
|
+
return { ok: true };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
546
1213
|
// Click the most specific visible element matching text, for custom widgets/menus
|
|
547
1214
|
// (dropdowns, flair pickers) whose options aren't standard controls in the table.
|
|
548
1215
|
async function centerOfText(tabId, text) {
|
|
@@ -576,17 +1243,163 @@ async function centerOfText(tabId, text) {
|
|
|
576
1243
|
})()`);
|
|
577
1244
|
}
|
|
578
1245
|
|
|
579
|
-
async function clickAt(tabId, x, y) {
|
|
1246
|
+
async function clickAt(tabId, x, y, opts) {
|
|
1247
|
+
const button = (opts && opts.button) || 'left', count = Math.max(1, Math.min(3, Number(opts && opts.count) || 1));
|
|
580
1248
|
await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
581
|
-
|
|
582
|
-
|
|
1249
|
+
// A double/triple click is successive press/release pairs with a rising clickCount.
|
|
1250
|
+
for (let n = 1; n <= count; n++) {
|
|
1251
|
+
await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: n });
|
|
1252
|
+
await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: n });
|
|
1253
|
+
}
|
|
583
1254
|
}
|
|
584
1255
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1256
|
+
/* ------------------------------ Waiting (settle) ---------------------------- *
|
|
1257
|
+
* Wait for what the page is ACTUALLY doing instead of a fixed delay: a navigation in progress (until
|
|
1258
|
+
* the new document's DOMContentLoaded), fetch/XHR requests started by the action, then a short
|
|
1259
|
+
* DOM-quiet window (MutationObserver). Each phase is capped, so long-polling, analytics beacons or a
|
|
1260
|
+
* ticking clock can't stall us, and a click that does nothing returns in a few tens of ms. */
|
|
1261
|
+
const watches = new Map(); // tabId -> { mainFrame, inflight: Map(reqId -> startedAt), navStart, navDone }
|
|
1262
|
+
function tabWatch(tabId) {
|
|
1263
|
+
let w = watches.get(tabId);
|
|
1264
|
+
if (!w) { w = { mainFrame: null, inflight: new Map(), navStart: 0, navDone: 0, committed: true, navReq: null }; watches.set(tabId, w); }
|
|
1265
|
+
return w;
|
|
1266
|
+
}
|
|
1267
|
+
const TRACKED = new Set(['Fetch', 'XHR', 'Document']);
|
|
1268
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1269
|
+
if (source.tabId == null) return;
|
|
1270
|
+
const w = watches.get(source.tabId);
|
|
1271
|
+
if (!w) return;
|
|
1272
|
+
// A request can finish on a different session than it started on (an iframe's document request
|
|
1273
|
+
// is announced by the parent, completed by the child): accept completions from any session.
|
|
1274
|
+
if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
|
|
1275
|
+
w.inflight.delete(params.requestId);
|
|
1276
|
+
if (method === 'Network.loadingFailed' && params.requestId === w.navReq && !w.committed) w.navDone = Date.now(); // blocked/aborted navigation
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (source.sessionId) return; // everything else: the top-level target only
|
|
1280
|
+
const now = Date.now();
|
|
1281
|
+
const begin = (loaderId) => { w.navStart = now; w.navDone = 0; w.committed = false; w.navReq = loaderId || null; w.netIdle = 0; };
|
|
1282
|
+
switch (method) {
|
|
1283
|
+
case 'Network.requestWillBeSent':
|
|
1284
|
+
// Documents: only the main frame's (subframe documents finish on other sessions / may never).
|
|
1285
|
+
if (TRACKED.has(params.type) && (params.type !== 'Document' || params.frameId === w.mainFrame)) w.inflight.set(params.requestId, now);
|
|
1286
|
+
if (params.type === 'Document' && params.frameId === w.mainFrame && params.requestId === params.loaderId && w.navReq !== params.requestId) {
|
|
1287
|
+
if (w.navDone >= w.navStart) begin(params.requestId); else w.navReq = params.requestId;
|
|
1288
|
+
}
|
|
1289
|
+
break;
|
|
1290
|
+
case 'Network.responseReceived':
|
|
1291
|
+
// A 204/205 navigation never replaces the document: it's over as soon as the response lands.
|
|
1292
|
+
if (params.requestId === w.navReq && (params.response.status === 204 || params.response.status === 205)) w.navDone = now;
|
|
1293
|
+
break;
|
|
1294
|
+
case 'Page.frameRequestedNavigation': case 'Page.frameStartedNavigating':
|
|
1295
|
+
if (params.frameId === w.mainFrame && params.navigationType !== 'sameDocument') begin(params.loaderId);
|
|
1296
|
+
break;
|
|
1297
|
+
case 'Page.frameNavigated':
|
|
1298
|
+
if (!params.frame.parentId) { w.mainFrame = params.frame.id; w.committed = true; if (params.frame.loaderId) w.navReq = params.frame.loaderId; }
|
|
1299
|
+
break;
|
|
1300
|
+
// The OLD document can still fire load/stop events after a navigation starts: only events that
|
|
1301
|
+
// follow the new document's commit (frameNavigated) mean "arrived".
|
|
1302
|
+
case 'Page.domContentEventFired': case 'Page.loadEventFired':
|
|
1303
|
+
if (w.committed) w.navDone = now;
|
|
1304
|
+
break;
|
|
1305
|
+
case 'Page.frameStoppedLoading':
|
|
1306
|
+
if (params.frameId === w.mainFrame && w.committed) w.navDone = now;
|
|
1307
|
+
break;
|
|
1308
|
+
case 'Page.lifecycleEvent':
|
|
1309
|
+
// Tied to the NEW document's loader, so the old page's late events can't end a wait.
|
|
1310
|
+
if (params.frameId === w.mainFrame && params.loaderId && params.loaderId === w.navReq) {
|
|
1311
|
+
if (params.name === 'DOMContentLoaded' || params.name === 'load') { w.committed = true; w.navDone = now; }
|
|
1312
|
+
if (params.name === 'networkAlmostIdle' || params.name === 'networkIdle') w.netIdle = now;
|
|
1313
|
+
}
|
|
1314
|
+
break;
|
|
1315
|
+
case 'Page.downloadWillBegin': case 'Page.navigatedWithinDocument':
|
|
1316
|
+
w.navDone = now;
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
// -> [ms since last DOM change, was the DOM already busy in the 600ms before `since` (epoch ms)?]
|
|
1322
|
+
const quietExpr = (since) => `(function(){ ${MO_INSTALL}
|
|
1323
|
+
var now=performance.now(), s=${Number(since) || 0}-Date.now()+now, b={};
|
|
1324
|
+
M.times.forEach(function(t){ if(t<s && t>=s-600) b[Math.floor((s-t)/100)]=1; });
|
|
1325
|
+
return [now-M.last, Object.keys(b).length>=4];
|
|
1326
|
+
})()`;
|
|
1327
|
+
|
|
1328
|
+
// Network tracking only while an action is being watched (see attach()).
|
|
1329
|
+
async function netOn(tabId) {
|
|
1330
|
+
const w = tabWatch(tabId);
|
|
1331
|
+
if (w.net) return;
|
|
1332
|
+
w.net = true;
|
|
1333
|
+
await sendCdp(tabId, 'Network.enable', { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }).catch(() => { w.net = false; });
|
|
1334
|
+
}
|
|
1335
|
+
async function netOff(tabId) {
|
|
1336
|
+
const w = tabWatch(tabId);
|
|
1337
|
+
if (!w.net) return;
|
|
1338
|
+
w.net = false; w.inflight.clear();
|
|
1339
|
+
await sendCdp(tabId, 'Network.disable').catch(() => {});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function settle(tabId, capMs, since, opts) {
|
|
1343
|
+
const start = since || Date.now();
|
|
1344
|
+
const w = tabWatch(tabId);
|
|
1345
|
+
const deadline = start + (capMs || 3000);
|
|
1346
|
+
// After typing, search boxes commonly DEBOUNCE (wait ~150-300ms of no typing, then fetch): there's
|
|
1347
|
+
// no signal to follow during that gap, so give a typed field a grace window for a request or a
|
|
1348
|
+
// re-render to begin before calling it idle.
|
|
1349
|
+
const grace = start + ((opts && opts.grace) || 0);
|
|
1350
|
+
// Let the action's handlers run first (event loop turn + a frame).
|
|
1351
|
+
await sleep(25);
|
|
1352
|
+
let idleSince = 0;
|
|
1353
|
+
// Which requests are the action's (vs. background beacons/polling that never stop): those that
|
|
1354
|
+
// start within 500ms of the action — or of the new page's DOMContentLoaded — plus requests that
|
|
1355
|
+
// start right after one of those finishes (a fetch chain). Anything else is ignored.
|
|
1356
|
+
const rel = new Set();
|
|
1357
|
+
let windowEnd = start + 500, sawNav = false;
|
|
1358
|
+
for (;;) {
|
|
1359
|
+
const now = Date.now();
|
|
1360
|
+
const navigating = w.navStart >= start - 50 && w.navDone < w.navStart;
|
|
1361
|
+
// A navigation gets a longer allowance: the next page must actually arrive.
|
|
1362
|
+
if (now > (navigating ? Math.max(deadline, start + 10000) : deadline)) break;
|
|
1363
|
+
if (navigating) {
|
|
1364
|
+
// Once the new document has committed, stop network tracking: its (possibly hundreds of)
|
|
1365
|
+
// subresource requests would only slow the browser down. Lifecycle events take over.
|
|
1366
|
+
if (w.committed && w.net) await netOff(tabId);
|
|
1367
|
+
sawNav = true; await sleep(30); continue;
|
|
1368
|
+
}
|
|
1369
|
+
if (sawNav) {
|
|
1370
|
+
sawNav = false; windowEnd = Math.max(windowEnd, w.navDone + 500);
|
|
1371
|
+
if (!w.net) {
|
|
1372
|
+
// New page: let its initial data requests settle (Chrome's networkAlmostIdle), capped.
|
|
1373
|
+
// Ends early once the document is fully loaded and the DOM has been quiet for 300ms.
|
|
1374
|
+
const until = Math.min(deadline, w.navDone + 800);
|
|
1375
|
+
while (!(w.netIdle >= w.navStart) && Date.now() < until) {
|
|
1376
|
+
try {
|
|
1377
|
+
const [q, , rs] = await evaluate(tabId, `(function(){ var r=${quietExpr(start)}; return [r[0], r[1], document.readyState]; })()`);
|
|
1378
|
+
if (rs === 'complete' && q >= 300) break;
|
|
1379
|
+
} catch {}
|
|
1380
|
+
await sleep(40);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
let busy = false;
|
|
1385
|
+
for (const [id, t] of w.inflight) {
|
|
1386
|
+
if (now - t > 15000) { w.inflight.delete(id); continue; } // leaked / long-poll: forget it
|
|
1387
|
+
if (!rel.has(id) && t >= start - 50 && t <= windowEnd) rel.add(id);
|
|
1388
|
+
}
|
|
1389
|
+
for (const id of rel) {
|
|
1390
|
+
if (!w.inflight.has(id)) { rel.delete(id); windowEnd = Math.max(windowEnd, now + 150); continue; } // finished: allow a follow-up
|
|
1391
|
+
if (now - w.inflight.get(id) < 8000) busy = true;
|
|
1392
|
+
}
|
|
1393
|
+
if (busy) { idleSince = 0; await sleep(30); continue; }
|
|
1394
|
+
if (now < grace) { await sleep(30); continue; } // debounce window: a request may still be coming
|
|
1395
|
+
if (!idleSince) idleSince = now;
|
|
1396
|
+
let quiet = 1e9, ambient = false;
|
|
1397
|
+
try { [quiet, ambient] = await evaluate(tabId, quietExpr(start)); } catch { await sleep(30); continue; } // document swapping
|
|
1398
|
+
// DOM still changing: wait for 60ms of quiet, capped at 600ms after the network went idle, or
|
|
1399
|
+
// 120ms on a page that was already constantly mutating (clocks, tickers, carousels) before us.
|
|
1400
|
+
if (quiet < 60 && now - idleSince < (ambient ? 120 : 600)) { await sleep(Math.max(10, 60 - quiet)); continue; }
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
590
1403
|
}
|
|
591
1404
|
|
|
592
1405
|
// After typing into a combobox, wait for its autocomplete options to actually render
|
|
@@ -600,7 +1413,7 @@ async function waitForOptions(tabId, ref, ms) {
|
|
|
600
1413
|
await evaluate(tabId, `new Promise(function(res){
|
|
601
1414
|
var done=false; function fin(){ if(done) return; done=true; try{clearInterval(iv);}catch(_){} res(1); }
|
|
602
1415
|
setTimeout(fin, ${cap});
|
|
603
|
-
var c=window.__pawbrowse; var
|
|
1416
|
+
var c=window.__pawbrowse; var e=(c&&c.get)?c.get(${R}):null;
|
|
604
1417
|
if(!e || (e.getAttribute('role')||'').toLowerCase()!=='combobox'){ return fin(); }
|
|
605
1418
|
var ids=(e.getAttribute('aria-controls')||e.getAttribute('aria-owns')||'').split(/\\s+/).filter(Boolean);
|
|
606
1419
|
var iv=setInterval(function(){
|
|
@@ -624,15 +1437,202 @@ const KEYMAP = {
|
|
|
624
1437
|
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
|
|
625
1438
|
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
|
|
626
1439
|
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
|
|
1440
|
+
Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32, text: ' ' },
|
|
1441
|
+
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
|
|
1442
|
+
Home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
|
|
1443
|
+
End: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
|
|
1444
|
+
PageUp: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
|
|
1445
|
+
PageDown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
|
|
627
1446
|
};
|
|
628
1447
|
|
|
1448
|
+
// "Shift+Tab", "Mod+A" (Cmd on macOS, Ctrl elsewhere), "Control+Enter", "F5", "a", "?" ...
|
|
1449
|
+
const MODS = { Alt: 1, Option: 1, Control: 2, Ctrl: 2, Meta: 4, Cmd: 4, Command: 4, Shift: 8, Mod: IS_MAC ? 4 : 2 };
|
|
1450
|
+
// macOS editing shortcuts aren't bound to Cmd+key for synthetic events: they need the command name.
|
|
1451
|
+
const MAC_COMMANDS = { a: 'selectAll', c: 'copy', v: 'paste', x: 'cut', z: 'undo' };
|
|
1452
|
+
function keyDef(name) {
|
|
1453
|
+
if (KEYMAP[name]) return { ...KEYMAP[name] };
|
|
1454
|
+
if (name.length === 1) {
|
|
1455
|
+
const up = name.toUpperCase();
|
|
1456
|
+
const alnum = /[a-z0-9]/i.test(name);
|
|
1457
|
+
return { key: name, code: /[a-z]/i.test(name) ? `Key${up}` : /[0-9]/.test(name) ? `Digit${name}` : '', windowsVirtualKeyCode: alnum ? up.charCodeAt(0) : name.charCodeAt(0), text: name };
|
|
1458
|
+
}
|
|
1459
|
+
const f = /^F(\d{1,2})$/.exec(name);
|
|
1460
|
+
if (f) return { key: name, code: name, windowsVirtualKeyCode: 111 + Number(f[1]) };
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
async function pressKey(tabId, combo) {
|
|
1464
|
+
const parts = String(combo).split(/\+(?!$)/); // "Control++" -> ["Control", "+"]
|
|
1465
|
+
let mods = 0;
|
|
1466
|
+
for (const m of parts.slice(0, -1)) { if (!(m in MODS)) return `unknown modifier "${m}"`; mods |= MODS[m]; }
|
|
1467
|
+
const def = keyDef(parts[parts.length - 1]);
|
|
1468
|
+
if (!def) return `key "${combo}" not supported`;
|
|
1469
|
+
if ((mods & 8) && def.text && def.text.length === 1) { def.key = def.text = def.text.toUpperCase(); }
|
|
1470
|
+
if (mods & 6) delete def.text; // Ctrl/Cmd chords are shortcuts, not text
|
|
1471
|
+
const commands = IS_MAC && (mods & 4) && MAC_COMMANDS[String(def.key).toLowerCase()] ? [(mods & 8) && def.key.toLowerCase() === 'z' ? 'redo' : MAC_COMMANDS[def.key.toLowerCase()]] : undefined;
|
|
1472
|
+
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: def.text ? 'keyDown' : 'rawKeyDown', ...def, modifiers: mods, commands });
|
|
1473
|
+
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...def, text: undefined, modifiers: mods });
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/* ---------------------------------- WebMCP --------------------------------- *
|
|
1478
|
+
* Pages that implement WebMCP (navigator.modelContext.registerTool / <form toolname>) describe
|
|
1479
|
+
* their own actions with JSON schemas. Calling one is a single deterministic step instead of a
|
|
1480
|
+
* dozen clicks, so observe lists them first and op "tool" invokes them via the WebMCP CDP domain. */
|
|
1481
|
+
const webTools = new Map(); // tabId -> Map(name -> { tool, where })
|
|
1482
|
+
const toolCalls = new Map(); // invocationId -> resolve
|
|
1483
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1484
|
+
const tabId = source.tabId;
|
|
1485
|
+
if (tabId == null) return;
|
|
1486
|
+
const where = source.sessionId ? { tabId, sessionId: source.sessionId } : tabId;
|
|
1487
|
+
if (method === 'WebMCP.toolsAdded') {
|
|
1488
|
+
let m = webTools.get(tabId); if (!m) { m = new Map(); webTools.set(tabId, m); }
|
|
1489
|
+
for (const t of params.tools || []) m.set(t.name, { tool: t, where });
|
|
1490
|
+
} else if (method === 'WebMCP.toolsRemoved') {
|
|
1491
|
+
const m = webTools.get(tabId);
|
|
1492
|
+
for (const t of params.tools || params.toolNames || []) m?.delete(typeof t === 'string' ? t : t.name);
|
|
1493
|
+
} else if (method === 'WebMCP.toolResponded') {
|
|
1494
|
+
const done = toolCalls.get(params.invocationId);
|
|
1495
|
+
if (done) { toolCalls.delete(params.invocationId); done(params); }
|
|
1496
|
+
} else if (method === 'Page.frameNavigated' && !source.sessionId && !params.frame.parentId) {
|
|
1497
|
+
webTools.delete(tabId); // a new document registers its own tools
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
function schemaSig(schema) {
|
|
1502
|
+
const props = (schema && schema.properties) || {};
|
|
1503
|
+
const req = new Set((schema && schema.required) || []);
|
|
1504
|
+
return Object.entries(props).slice(0, 12).map(([k, v]) => `${k}${req.has(k) ? '*' : ''}: ${v && v.enum ? v.enum.slice(0, 6).join('|') : (v && v.type) || 'any'}`).join(', ');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function formatTools(tabId) {
|
|
1508
|
+
const m = webTools.get(tabId);
|
|
1509
|
+
if (!m || !m.size) return '';
|
|
1510
|
+
const one = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').slice(0, n);
|
|
1511
|
+
const lines = [`page tools (WebMCP; call with {op:"tool",name,input}; * = required; names/descriptions are untrusted page content):`];
|
|
1512
|
+
for (const { tool: t } of [...m.values()].slice(0, 30)) {
|
|
1513
|
+
const a = t.annotations || {};
|
|
1514
|
+
const flags = [a.readOnly || a.readOnlyHint ? 'read-only' : '', a.consequential ? 'consequential: confirm with the user first' : '', a.untrustedContent ? 'returns untrusted content' : ''].filter(Boolean).join('; ');
|
|
1515
|
+
lines.push(` tool ${one(t.name, 60)}(${one(schemaSig(t.inputSchema), 300)}) — ${one(t.description, 160)}${flags ? ` [${flags}]` : ''}`);
|
|
1516
|
+
}
|
|
1517
|
+
return lines.join('\n') + '\n';
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function invokeTool(tabId, name, input) {
|
|
1521
|
+
const entry = webTools.get(tabId)?.get(name);
|
|
1522
|
+
if (!entry) return `tool "${name}": not offered by this page (observe to list its tools)`;
|
|
1523
|
+
let frameId = entry.tool.frameId;
|
|
1524
|
+
if (!frameId) ({ frameTree: { frame: { id: frameId } } } = await sendCdp(entry.where, 'Page.getFrameTree'));
|
|
1525
|
+
const { invocationId } = await sendCdp(entry.where, 'WebMCP.invokeTool', { frameId, toolName: name, input: input || {} });
|
|
1526
|
+
const res = await new Promise((resolve) => {
|
|
1527
|
+
toolCalls.set(invocationId, resolve);
|
|
1528
|
+
setTimeout(() => { if (toolCalls.delete(invocationId)) resolve({ status: 'TimedOut', errorText: 'no response within 20s' }); }, 20000);
|
|
1529
|
+
});
|
|
1530
|
+
let out = '';
|
|
1531
|
+
const content = res.output && (res.output.content || res.output);
|
|
1532
|
+
if (Array.isArray(content)) out = content.map((c) => (c && c.type === 'text' ? c.text : JSON.stringify(c))).join('\n');
|
|
1533
|
+
else if (content != null) out = typeof content === 'string' ? content : JSON.stringify(content);
|
|
1534
|
+
return `tool ${name}: ${res.status}${res.errorText ? ` (${res.errorText})` : ''}${out ? `\n output (untrusted page data): ${out.slice(0, 2000).replace(/\n/g, '\n ')}` : ''}`;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const dragIntercepts = new Map(); // tabId -> drag data captured by Input.dragIntercepted
|
|
1538
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
1539
|
+
if (method === 'Input.dragIntercepted' && source.tabId != null) dragIntercepts.set(source.tabId, params.data);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
async function drag(tabId, from, to) {
|
|
1543
|
+
const mouse = (type, p, extra) => sendCdp(tabId, 'Input.dispatchMouseEvent', { type, x: p.x, y: p.y, button: 'left', ...extra });
|
|
1544
|
+
dragIntercepts.delete(tabId);
|
|
1545
|
+
await sendCdp(tabId, 'Input.setInterceptDrags', { enabled: true }).catch(() => {});
|
|
1546
|
+
try {
|
|
1547
|
+
await mouse('mouseMoved', from);
|
|
1548
|
+
await mouse('mousePressed', from, { clickCount: 1, buttons: 1 });
|
|
1549
|
+
// Move in steps: libraries only start a drag after a few pixels and track intermediate moves.
|
|
1550
|
+
const steps = 8;
|
|
1551
|
+
for (let i = 1; i <= steps; i++) {
|
|
1552
|
+
const p = { x: Math.round(from.x + (to.x - from.x) * i / steps), y: Math.round(from.y + (to.y - from.y) * i / steps) };
|
|
1553
|
+
await mouse('mouseMoved', p, { buttons: 1 });
|
|
1554
|
+
const data = dragIntercepts.get(tabId);
|
|
1555
|
+
if (data) {
|
|
1556
|
+
// Native HTML5 drag started: deliver it to the drop target and finish there.
|
|
1557
|
+
for (const type of ['dragEnter', 'dragOver', 'drop']) await sendCdp(tabId, 'Input.dispatchDragEvent', { type, x: to.x, y: to.y, data });
|
|
1558
|
+
await mouse('mouseReleased', to, { clickCount: 1 });
|
|
1559
|
+
return ' (html5 drop)';
|
|
1560
|
+
}
|
|
1561
|
+
await sleep(16);
|
|
1562
|
+
}
|
|
1563
|
+
await mouse('mouseReleased', to, { clickCount: 1 });
|
|
1564
|
+
return '';
|
|
1565
|
+
} finally {
|
|
1566
|
+
dragIntercepts.delete(tabId);
|
|
1567
|
+
sendCdp(tabId, 'Input.setInterceptDrags', { enabled: false }).catch(() => {});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
629
1571
|
async function runOp(tabId, op) {
|
|
1572
|
+
const pol = acting.get(tabId);
|
|
1573
|
+
if (pol) { pol.accept = op.dialog === 'accept' ? true : op.dialog === 'dismiss' ? false : undefined; pol.text = op.dialog_text; }
|
|
1574
|
+
// Route the ref to its frame: T is where page-side code runs, R the frame-local ref; top() turns
|
|
1575
|
+
// frame-local coordinates into top-level ones for input (which the browser routes to the frame).
|
|
1576
|
+
const rt = op.ref != null ? routeRef(tabId, op.ref) : { target: tabId, ref: op.ref, frame: null };
|
|
1577
|
+
if (op.ref != null && !rt.target) return `${op.ref}: unknown frame (observe again)`;
|
|
1578
|
+
const T = rt.target, REF = rt.ref;
|
|
1579
|
+
const top = async (x, y) => {
|
|
1580
|
+
if (!rt.frame) return { x, y };
|
|
1581
|
+
const off = await frameOffset(tabId, rt.frame);
|
|
1582
|
+
const p = { x: Math.round(x + off.x), y: Math.round(y + off.y) };
|
|
1583
|
+
// The frame's own hit-test can't see the PARENT page: make sure the point really lands in this
|
|
1584
|
+
// frame and not on a cookie banner / modal of the page around it.
|
|
1585
|
+
try {
|
|
1586
|
+
const hit = await sendCdp(tabId, 'DOM.getNodeForLocation', { x: p.x, y: p.y, includeUserAgentShadowDOM: false });
|
|
1587
|
+
const main = tabWatch(tabId).mainFrame;
|
|
1588
|
+
if (off.topOwner != null && hit.backendNodeId !== off.topOwner && (!hit.frameId || hit.frameId === main)) p.covered = true;
|
|
1589
|
+
} catch {}
|
|
1590
|
+
return p;
|
|
1591
|
+
};
|
|
630
1592
|
switch (op.op) {
|
|
1593
|
+
case 'dialog': {
|
|
1594
|
+
const d = openDialogs.get(tabId);
|
|
1595
|
+
if (!d) return 'dialog: no dialog is open';
|
|
1596
|
+
await sendCdp(d.where || tabId, 'Page.handleJavaScriptDialog', { accept: !!op.accept, promptText: op.text != null ? String(op.text) : '' });
|
|
1597
|
+
openDialogs.delete(tabId);
|
|
1598
|
+
return `dialog: ${d.type} "${String(d.message || '').slice(0, 120)}" → ${op.accept ? 'accepted' : 'dismissed'}`;
|
|
1599
|
+
}
|
|
631
1600
|
case 'click': {
|
|
632
|
-
const r = await resolveHit(
|
|
1601
|
+
const r = await resolveHit(T, REF);
|
|
633
1602
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
634
|
-
await
|
|
635
|
-
return
|
|
1603
|
+
const p = await top(r.x, r.y);
|
|
1604
|
+
if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
|
|
1605
|
+
const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
|
|
1606
|
+
await clickAt(tabId, p.x, p.y, { button, count: op.count });
|
|
1607
|
+
return `${op.count > 1 ? `${op.count}x ` : ''}${button !== 'left' ? `${button}-` : ''}click ${op.ref}`;
|
|
1608
|
+
}
|
|
1609
|
+
case 'hover': {
|
|
1610
|
+
const r = await resolveHit(T, REF);
|
|
1611
|
+
if (r.error) return `${op.ref}: ${r.error}`;
|
|
1612
|
+
const p = await top(r.x, r.y);
|
|
1613
|
+
await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y });
|
|
1614
|
+
return `hover ${op.ref}`;
|
|
1615
|
+
}
|
|
1616
|
+
case 'drag': {
|
|
1617
|
+
// Drag ref onto to:"eN" (or by dx/dy pixels). Pointer-driven widgets (sliders, sortable
|
|
1618
|
+
// lists) get a real press-move-release; native HTML5 drag-and-drop is intercepted by CDP and
|
|
1619
|
+
// replayed as dragEnter/dragOver/drop on the target.
|
|
1620
|
+
const a = await resolveHit(T, REF);
|
|
1621
|
+
if (a.error) return `${op.ref}: ${a.error}`;
|
|
1622
|
+
const from = await top(a.x, a.y);
|
|
1623
|
+
let to;
|
|
1624
|
+
if (op.to_text) {
|
|
1625
|
+
const c = await centerOfText(tabId, op.to_text);
|
|
1626
|
+
if (!c) return `drag: drop target "${op.to_text}" not found`;
|
|
1627
|
+
to = c;
|
|
1628
|
+
} else if (op.to) {
|
|
1629
|
+
const rt2 = routeRef(tabId, op.to);
|
|
1630
|
+
if (!rt2.target) return `${op.to}: unknown frame (observe again)`;
|
|
1631
|
+
const b = await resolveHit(rt2.target, rt2.ref, { noScroll: true, noHit: true });
|
|
1632
|
+
if (b.error) return `${op.to}: ${b.error}`;
|
|
1633
|
+
to = rt2.frame ? await (async () => { const off = await frameOffset(tabId, rt2.frame); return { x: Math.round(b.x + off.x), y: Math.round(b.y + off.y) }; })() : { x: b.x, y: b.y };
|
|
1634
|
+
} else to = { x: from.x + Number(op.dx || 0), y: from.y + Number(op.dy || 0) };
|
|
1635
|
+
return drag(tabId, from, to).then((how) => `drag ${op.ref} → ${op.to || (op.to_text ? `"${op.to_text}"` : `${op.dx || 0},${op.dy || 0}`)}${how}`);
|
|
636
1636
|
}
|
|
637
1637
|
case 'click_text': {
|
|
638
1638
|
const c = await centerOfText(tabId, op.text);
|
|
@@ -641,9 +1641,15 @@ async function runOp(tabId, op) {
|
|
|
641
1641
|
return `click_text "${op.text}"`;
|
|
642
1642
|
}
|
|
643
1643
|
case 'type': {
|
|
644
|
-
const r = await resolveHit(
|
|
1644
|
+
const r = await resolveHit(T, REF, { fill: true });
|
|
645
1645
|
if (r.error) return `${op.ref}: ${r.error}`;
|
|
646
|
-
|
|
1646
|
+
if (r.set) {
|
|
1647
|
+
const sv = await setValue(T, REF, op.text);
|
|
1648
|
+
return sv.error ? `${op.ref}: ${sv.error}` : `type ${op.ref} (set to "${sv.value}")`;
|
|
1649
|
+
}
|
|
1650
|
+
const p = await top(r.x, r.y);
|
|
1651
|
+
if (p.covered) return `${op.ref}: element is covered by another element of the page around its frame (dismiss the overlay first)`;
|
|
1652
|
+
await clickAt(tabId, p.x, p.y); // focus the field with a trusted click
|
|
647
1653
|
// Select-all then insert — robust for React/controlled inputs.
|
|
648
1654
|
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: IS_MAC ? 4 : 2, commands: ['selectAll'] });
|
|
649
1655
|
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: IS_MAC ? 4 : 2 });
|
|
@@ -655,23 +1661,25 @@ async function runOp(tabId, op) {
|
|
|
655
1661
|
} else {
|
|
656
1662
|
await sendCdp(tabId, 'Input.insertText', { text: txt });
|
|
657
1663
|
}
|
|
658
|
-
await waitForOptions(
|
|
1664
|
+
await waitForOptions(T, REF, 250); // let autocomplete suggestions render
|
|
659
1665
|
return `type ${op.ref}`;
|
|
660
1666
|
}
|
|
661
1667
|
case 'select': {
|
|
662
|
-
const R = JSON.stringify(String(
|
|
663
|
-
const
|
|
1668
|
+
const R = JSON.stringify(String(REF));
|
|
1669
|
+
const VS = JSON.stringify([].concat(op.values ?? op.value ?? '').map(String));
|
|
664
1670
|
try {
|
|
665
|
-
const res = await evaluate(
|
|
666
|
-
var c=window.__pawbrowse; var
|
|
667
|
-
var e=node!=null?c.nodes.get(node):null;
|
|
1671
|
+
const res = await evaluate(T, `(function(){
|
|
1672
|
+
var c=window.__pawbrowse; var e=(c&&c.get)?c.get(${R}):null;
|
|
668
1673
|
if(!e||!e.isConnected) return 'unknown ref (observe again)';
|
|
1674
|
+
if(c.guards && c.guards[${R}]!=null && c.guard(e)!==c.guards[${R}]) return 'element changed since observe (observe again)';
|
|
669
1675
|
if(e.tagName!=='SELECT') return 'not a dropdown';
|
|
670
1676
|
if(e.matches(':disabled')||e.closest('[aria-disabled="true"],[inert]')) return 'dropdown is disabled';
|
|
671
1677
|
if(!e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})) return 'dropdown not visible';
|
|
672
|
-
var
|
|
673
|
-
|
|
674
|
-
if(
|
|
1678
|
+
var vals=${VS}, m=0, ok=function(o){ return !o.disabled && !(o.closest&&o.closest('optgroup[disabled]')); };
|
|
1679
|
+
var hit=function(o){ return vals.some(function(v){ return o.value===v||o.label===v||o.text===v; }); };
|
|
1680
|
+
if(e.multiple){ for(var i=0;i<e.options.length;i++){ var o=e.options[i]; var want=ok(o)&&hit(o); if(want) m++; o.selected=want; } }
|
|
1681
|
+
else for(var j=0;j<e.options.length;j++){ if(ok(e.options[j]) && hit(e.options[j])){ e.selectedIndex=j; m=1; break; } }
|
|
1682
|
+
if(m<vals.length) return m ? 'some options not found' : 'option not found';
|
|
675
1683
|
e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
|
|
676
1684
|
return 'ok';
|
|
677
1685
|
})()`);
|
|
@@ -681,19 +1689,60 @@ async function runOp(tabId, op) {
|
|
|
681
1689
|
return `select ${op.ref}: may have applied and navigated the page; observe again before retrying`;
|
|
682
1690
|
}
|
|
683
1691
|
}
|
|
1692
|
+
case 'upload': {
|
|
1693
|
+
const paths = [].concat(op.paths ?? op.path ?? []).map(String).filter(Boolean);
|
|
1694
|
+
if (!paths.length) return `${op.ref}: upload needs paths:["/absolute/file"]`;
|
|
1695
|
+
const u = await uploadFiles(T, REF, paths);
|
|
1696
|
+
return u.error ? `${op.ref}: ${u.error}` : `upload ${op.ref} (${paths.length} file${paths.length > 1 ? 's' : ''})`;
|
|
1697
|
+
}
|
|
1698
|
+
case 'click_xy': {
|
|
1699
|
+
// Coordinates from the last browser_screenshot image (converted to CSS px).
|
|
1700
|
+
const k = shotScale.get(tabId) || 1;
|
|
1701
|
+
const x = Math.round(Number(op.x) * k), y = Math.round(Number(op.y) * k);
|
|
1702
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return 'click_xy needs numeric x and y';
|
|
1703
|
+
const button = ['right', 'middle'].includes(op.button) ? op.button : 'left';
|
|
1704
|
+
await clickAt(tabId, x, y, { button, count: op.count });
|
|
1705
|
+
return `click_xy ${op.x},${op.y}`;
|
|
1706
|
+
}
|
|
1707
|
+
case 'tool': {
|
|
1708
|
+
return invokeTool(tabId, String(op.name || ''), op.input);
|
|
1709
|
+
}
|
|
684
1710
|
case 'key': {
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...k });
|
|
688
|
-
await sendCdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...k });
|
|
689
|
-
return `key ${op.key}`;
|
|
1711
|
+
const err = await pressKey(tabId, op.key);
|
|
1712
|
+
return err || `key ${op.key}`;
|
|
690
1713
|
}
|
|
691
1714
|
case 'scroll': {
|
|
692
1715
|
const dy = Number(op.dy ?? 600);
|
|
693
|
-
// Real wheel event so overflow containers, virtualized lists, and infinite scroll fire.
|
|
1716
|
+
// Real wheel event so overflow containers, virtualized lists, and infinite scroll fire. With a
|
|
1717
|
+
// ref, the wheel goes to THAT element (a side panel, a dropdown list, a chat pane) instead of
|
|
1718
|
+
// the middle of the page, so the right box scrolls.
|
|
694
1719
|
let cx = 400, cy = 400;
|
|
695
|
-
try {
|
|
1720
|
+
try {
|
|
1721
|
+
const R = JSON.stringify(String(REF || ''));
|
|
1722
|
+
const c = await evaluate(T, `(function(){
|
|
1723
|
+
var c=window.__pawbrowse, e=${R}&&c&&c.get&&c.get(${R});
|
|
1724
|
+
if(e&&e.isConnected){ var s=c.surface?c.surface(e):e; if(s){ var r=s.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, w=s.ownerDocument.defaultView;
|
|
1725
|
+
while(w&&w.frameElement){ var fr=w.frameElement.getBoundingClientRect(); x+=fr.left+w.frameElement.clientLeft; y+=fr.top+w.frameElement.clientTop; w=w.frameElement.ownerDocument.defaultView; }
|
|
1726
|
+
if(x>=0&&y>=0&&x<innerWidth&&y<innerHeight) return [Math.round(x),Math.round(y)]; } }
|
|
1727
|
+
return [Math.round(innerWidth/2),Math.round(innerHeight/2)];
|
|
1728
|
+
})()`);
|
|
1729
|
+
if (Array.isArray(c)) ({ x: cx, y: cy } = await top(c[0], c[1]));
|
|
1730
|
+
} catch {}
|
|
1731
|
+
// Scroll offsets of the page and every scroll container under the point, as one signature.
|
|
1732
|
+
const probe = rt.frame ? null : `(function(){ var s=[scrollX,scrollY], e=document.elementFromPoint(${cx},${cy}); for(var g=0;e&&g<60;g++){ if(e.scrollHeight>e.clientHeight||e.scrollWidth>e.clientWidth) s.push(e.scrollTop,e.scrollLeft); e=e.parentElement||(e.getRootNode&&e.getRootNode().host); } return s.join(','); })()`;
|
|
1733
|
+
const before = probe ? await evaluate(tabId, probe).catch(() => null) : null;
|
|
696
1734
|
await sendCdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseWheel', x: cx, y: cy, deltaX: 0, deltaY: dy });
|
|
1735
|
+
// Wheel scrolling is often ANIMATED (smooth scrolling, e.g. on Linux): wait until the offsets
|
|
1736
|
+
// stop moving, so the table we return shows where the page actually ended up.
|
|
1737
|
+
if (probe) {
|
|
1738
|
+
let last = before, stable = 0;
|
|
1739
|
+
for (const end = Date.now() + 1500; Date.now() < end;) {
|
|
1740
|
+
await sleep(30);
|
|
1741
|
+
const now = await evaluate(tabId, probe).catch(() => null);
|
|
1742
|
+
if (now !== last) { last = now; stable = 0; continue; }
|
|
1743
|
+
if (++stable >= 3 && (now !== before || Date.now() > end - 1200)) break; // settled (or nothing scrolls here)
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
697
1746
|
return `scroll ${dy}`;
|
|
698
1747
|
}
|
|
699
1748
|
case 'wait': {
|
|
@@ -705,6 +1754,50 @@ async function runOp(tabId, op) {
|
|
|
705
1754
|
}
|
|
706
1755
|
}
|
|
707
1756
|
|
|
1757
|
+
/* -------------------------------- Screenshots ------------------------------ *
|
|
1758
|
+
* For what the element table can't express — canvas apps (Docs/Sheets/Figma/maps), charts, visual
|
|
1759
|
+
* state. The image is in CSS pixels (so it maps 1:1 to click_xy on any display density), capped at
|
|
1760
|
+
* 1568px on the long side, and, where OffscreenCanvas exists, labelled with the table's refs. */
|
|
1761
|
+
const shotScale = new Map(); // tabId -> CSS px per image px of the last screenshot
|
|
1762
|
+
|
|
1763
|
+
async function screenshot(tabId, opts) {
|
|
1764
|
+
const [dpr, vw, vh] = await evaluate(tabId, '[devicePixelRatio, innerWidth, innerHeight]');
|
|
1765
|
+
const { cssVisualViewport: vv } = await sendCdp(tabId, 'Page.getLayoutMetrics');
|
|
1766
|
+
const fit = Math.min(1, 1568 / Math.max(vw, vh));
|
|
1767
|
+
const shot = await sendCdp(tabId, 'Page.captureScreenshot', {
|
|
1768
|
+
format: 'jpeg', quality: 70,
|
|
1769
|
+
clip: { x: vv.pageX, y: vv.pageY, width: vw, height: vh, scale: fit / dpr },
|
|
1770
|
+
});
|
|
1771
|
+
shotScale.set(tabId, 1 / fit);
|
|
1772
|
+
let data = shot.data, marked = 0;
|
|
1773
|
+
if (opts && opts.marks !== false && typeof OffscreenCanvas !== 'undefined' && typeof createImageBitmap !== 'undefined') {
|
|
1774
|
+
try {
|
|
1775
|
+
const snap = await snapshot(tabId, 1);
|
|
1776
|
+
const frames = snap ? await readFrames(tabId, tabId, snap, []) : [];
|
|
1777
|
+
// Label at each control's top-left corner (not its centre, which would hide its text).
|
|
1778
|
+
const rows = (snap ? snap.actions.filter((a) => !a.off) : []).map((a) => ({ id: a.id, x: a.x - (a.w || 0) / 2, y: a.y - (a.h || 0) / 2 }));
|
|
1779
|
+
for (const { f, off, snap: fs } of frames) for (const a of fs.actions) if (!a.off) rows.push({ id: `f${f.idx}.${a.id}`, x: a.x - (a.w || 0) / 2 + off.x, y: a.y - (a.h || 0) / 2 + off.y });
|
|
1780
|
+
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
|
1781
|
+
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
|
|
1782
|
+
const cv = new OffscreenCanvas(bmp.width, bmp.height), g = cv.getContext('2d');
|
|
1783
|
+
g.drawImage(bmp, 0, 0);
|
|
1784
|
+
g.font = 'bold 11px sans-serif'; g.textBaseline = 'top';
|
|
1785
|
+
for (const r of rows) {
|
|
1786
|
+
const w = g.measureText(r.id).width + 4, x = Math.max(0, r.x * fit - 2), y = Math.max(0, r.y * fit - 9);
|
|
1787
|
+
g.fillStyle = 'rgba(220,38,38,.85)'; g.fillRect(x, y, w, 13);
|
|
1788
|
+
g.fillStyle = '#fff'; g.fillText(r.id, x + 2, y + 1);
|
|
1789
|
+
marked++;
|
|
1790
|
+
}
|
|
1791
|
+
const out = await cv.convertToBlob({ type: 'image/jpeg', quality: 0.7 });
|
|
1792
|
+
const buf = new Uint8Array(await out.arrayBuffer());
|
|
1793
|
+
let bin = ''; for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
|
|
1794
|
+
data = btoa(bin);
|
|
1795
|
+
} catch { /* unlabelled screenshot is still useful */ }
|
|
1796
|
+
}
|
|
1797
|
+
const w = Math.round(vw * fit), h = Math.round(vh * fit);
|
|
1798
|
+
return { data, mimeType: 'image/jpeg', width: w, height: h, note: `screenshot ${w}x${h} of the visible viewport${marked ? `, ${marked} controls labelled with their refs` : ''}. For things not in the element table (canvas apps, maps, charts), act with {op:"click_xy",x,y} using THIS image's pixel coordinates.` };
|
|
1799
|
+
}
|
|
1800
|
+
|
|
708
1801
|
/* ------------------------------ Command router ---------------------------- */
|
|
709
1802
|
|
|
710
1803
|
async function handleCommand(cmd, args, token, session) {
|
|
@@ -738,58 +1831,98 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
738
1831
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) url = 'https://' + url; // bare domain -> https
|
|
739
1832
|
if (!/^https?:\/\//i.test(url)) throw new Error(`navigate only supports http(s) URLs (refusing "${url.split(':')[0]}:")`);
|
|
740
1833
|
await attach(tabId);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1834
|
+
// beforeunload ("Leave site? Changes may not be saved") is dismissed unless dialog:"accept".
|
|
1835
|
+
return whileActing(tabId, async () => {
|
|
1836
|
+
const t0 = Date.now();
|
|
1837
|
+
const w = tabWatch(tabId);
|
|
1838
|
+
w.navStart = t0; w.navDone = 0; w.committed = false; w.navReq = null; // the old document must not count as "arrived"
|
|
1839
|
+
const nav = await sendCdp(tabId, 'Page.navigate', { url });
|
|
1840
|
+
if (nav && nav.loaderId && !w.committed) w.navReq = nav.loaderId;
|
|
1841
|
+
worlds.delete(tabId); // the old document's world is going away with it
|
|
1842
|
+
if (nav && nav.errorText) {
|
|
1843
|
+
// Cancelled by a "leave site?" prompt we dismissed: not a failure — say so, show where we are.
|
|
1844
|
+
const dl0 = takeDialogLog(tabId);
|
|
1845
|
+
if (dl0 && /beforeunload/.test(dl0)) return `${dl0}(navigation cancelled: the page asked to keep unsaved changes)\n\n${await observe(tabId)}`;
|
|
1846
|
+
throw new Error(`navigation failed: ${nav.errorText}`);
|
|
748
1847
|
}
|
|
749
|
-
|
|
750
|
-
|
|
1848
|
+
if (nav && !nav.loaderId) w.navDone = Date.now(); // same-document (fragment) navigation
|
|
1849
|
+
await settle(tabId, 5000, t0);
|
|
1850
|
+
const dl = takeDialogLog(tabId);
|
|
1851
|
+
return (dl ? dl + '\n' : '') + await observe(tabId);
|
|
1852
|
+
}, { nav: true, accept: args.dialog === 'accept' ? true : undefined });
|
|
751
1853
|
}
|
|
752
1854
|
case 'observe': {
|
|
753
1855
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
754
1856
|
await attach(tabId);
|
|
755
|
-
|
|
1857
|
+
assertNoOpenDialog(tabId);
|
|
1858
|
+
return observe(tabId, args);
|
|
756
1859
|
}
|
|
757
1860
|
case 'read': {
|
|
758
1861
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
759
1862
|
await attach(tabId);
|
|
1863
|
+
assertNoOpenDialog(tabId);
|
|
760
1864
|
const max = Math.min(Number(args.max_chars) || 12000, 50000);
|
|
761
|
-
const
|
|
762
|
-
|
|
1865
|
+
const r = await evaluate(tabId, `${READ_TEXT}(${max})`);
|
|
1866
|
+
let text = r.text;
|
|
1867
|
+
// Cross-origin frames the page-side reader can't enter (embedded docs, widgets, checkouts).
|
|
1868
|
+
try {
|
|
1869
|
+
for (const { f } of await readFrames(tabId, tabId, await snapshot(tabId, 1), [])) {
|
|
1870
|
+
if (text.length >= max) break;
|
|
1871
|
+
try {
|
|
1872
|
+
const fr = await evaluate(f.target, `${READ_TEXT}(${max})`);
|
|
1873
|
+
if (fr && fr.text) text += `\n\n[frame f${f.idx}: ${fr.url}]\n${fr.text}`;
|
|
1874
|
+
} catch {}
|
|
1875
|
+
}
|
|
1876
|
+
} catch {}
|
|
1877
|
+
return `${r.title} — ${r.url}\n\n${text.slice(0, max)}`;
|
|
763
1878
|
}
|
|
764
1879
|
case 'act': {
|
|
765
1880
|
const ops = args.ops || [];
|
|
766
1881
|
if (ops.length > 50) throw new Error('too many ops in one call (max 50); split into smaller batches');
|
|
767
1882
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
768
1883
|
await attach(tabId);
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
await
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1884
|
+
return whileActing(tabId, async () => {
|
|
1885
|
+
const logLines = [];
|
|
1886
|
+
// Answer a dialog left open from before FIRST: until then the page can't run anything.
|
|
1887
|
+
let i = 0;
|
|
1888
|
+
for (; i < ops.length && ops[i].op === 'dialog'; i++) logLines.push(' ' + await runOp(tabId, ops[i]));
|
|
1889
|
+
assertNoOpenDialog(tabId);
|
|
1890
|
+
const before = await evaluate(tabId, SIG).catch(() => null);
|
|
1891
|
+
await netOn(tabId);
|
|
1892
|
+
try {
|
|
1893
|
+
for (; i < ops.length; i++) {
|
|
1894
|
+
const op = ops[i];
|
|
1895
|
+
if (aborted()) { logLines.push(' (aborted: command timed out; remaining ops not run)'); break; }
|
|
1896
|
+
const t0 = Date.now();
|
|
1897
|
+
try { logLines.push(' ' + await runOp(tabId, op)); }
|
|
1898
|
+
catch (e) { logLines.push(` ${op.op} ${op.ref || ''}: ERROR ${e.message}`); }
|
|
1899
|
+
if (op.op !== 'wait') await settle(tabId, i === ops.length - 1 ? 4000 : 2500, t0, { grace: op.op === 'type' ? 400 : 0 });
|
|
1900
|
+
}
|
|
1901
|
+
} finally { await netOff(tabId); }
|
|
1902
|
+
const after = await evaluate(tabId, SIG).catch(() => null);
|
|
1903
|
+
const seen = lastTable.get(tabId), seenFull = lastFull.get(tabId);
|
|
1904
|
+
// The ops already executed; a failed post-action read (page navigating) must NOT make
|
|
1905
|
+
// the caller think they failed and retry them.
|
|
1906
|
+
let table;
|
|
1907
|
+
try { table = await observe(tabId); } catch { table = null; }
|
|
1908
|
+
const changed = before == null || after == null || before !== after || table == null || seen == null || tableBody(table) !== seen;
|
|
1909
|
+
const note = changed ? 'page changed' : 'page did NOT change (if you expected an effect, the action may not have worked — try a different target)';
|
|
1910
|
+
if (table == null) {
|
|
1911
|
+
return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n(ops executed; the page is navigating and could not be read yet — call browser_observe next. Do NOT re-run these ops.)`;
|
|
1912
|
+
}
|
|
1913
|
+
return `ran ${ops.length} op(s) [${note}]:\n${logLines.join('\n')}\n${takeDialogLog(tabId)}\n${(changed && deltaTable(seenFull, table)) || table}`;
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
case 'screenshot': {
|
|
1917
|
+
const tabId = await resolveTabId(session, args, 'inspect');
|
|
1918
|
+
await attach(tabId);
|
|
1919
|
+
assertNoOpenDialog(tabId);
|
|
1920
|
+
return screenshot(tabId, args);
|
|
789
1921
|
}
|
|
790
1922
|
case 'assert': {
|
|
791
1923
|
const tabId = await resolveTabId(session, args, 'inspect');
|
|
792
1924
|
await attach(tabId);
|
|
1925
|
+
assertNoOpenDialog(tabId);
|
|
793
1926
|
if (args.contains != null) {
|
|
794
1927
|
const ok = await evaluate(tabId, `!!(document.body && document.body.innerText && document.body.innerText.indexOf(${JSON.stringify(args.contains)})>=0)`);
|
|
795
1928
|
return { pass: !!ok, kind: 'contains', value: args.contains };
|
|
@@ -799,7 +1932,8 @@ async function handleCommand(cmd, args, token, session) {
|
|
|
799
1932
|
return { pass: String(u).indexOf(args.url_includes) >= 0, kind: 'url_includes', url: u };
|
|
800
1933
|
}
|
|
801
1934
|
if (args.ref_visible != null) {
|
|
802
|
-
const
|
|
1935
|
+
const rt = routeRef(tabId, args.ref_visible);
|
|
1936
|
+
const r = rt.target ? await resolveHit(rt.target, rt.ref, { noScroll: true }) : { error: 'unknown frame (observe again)' };
|
|
803
1937
|
return { pass: !r.error, kind: 'ref_visible', ref: args.ref_visible, note: r.error };
|
|
804
1938
|
}
|
|
805
1939
|
return { pass: false, error: 'provide one of: contains, url_includes, ref_visible' };
|