@jobshimo/browser-link 0.35.0 → 0.36.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.
@@ -8,6 +8,52 @@
8
8
  */
9
9
  import { DEEP_QUERY_JS } from './deep-query.js';
10
10
  import { DOM_HELPERS_JS } from './dom-helpers.js';
11
+ import { OBSERVER_GLOBAL } from './observer.js';
12
+ /**
13
+ * In-page access to the page observer's element identity (spec v4 §5.6),
14
+ * injected verbatim into `buildSnapshotJs`, `buildFindJs` and `buildStateJs`.
15
+ * The observer global is looked up ONCE per evaluation and used only when it
16
+ * is present, not torn down, and exposes `refFor`:
17
+ * - `refOf(el)` returns the observer's `e<N>` ref for `el`, or `null`;
18
+ * - `withDocId(result)` adds `doc_id` to `result` and returns it;
19
+ * - `snapshotStore()` returns the observer when it keeps snapshot records
20
+ * (`recordSnapshot` / `getSnapshot`, used by `snapshot({ since })`), else
21
+ * `null`.
22
+ * Without the observer (injection failed, pre-observer document) all are
23
+ * no-ops, so every result is byte-identical to the pre-ref output.
24
+ */
25
+ export const PAGE_OBSERVER_JS = `
26
+ const pageObserver = (() => {
27
+ try {
28
+ const g = window[${JSON.stringify(OBSERVER_GLOBAL)}];
29
+ if (g && !g.dead && typeof g.refFor === 'function') return g;
30
+ } catch (e) {}
31
+ return null;
32
+ })();
33
+ function refOf(el) {
34
+ if (!pageObserver) return null;
35
+ try {
36
+ const ref = pageObserver.refFor(el);
37
+ return typeof ref === 'string' ? ref : null;
38
+ } catch (e) {
39
+ return null;
40
+ }
41
+ }
42
+ function withDocId(result) {
43
+ if (pageObserver && typeof pageObserver.doc_id === 'string') result.doc_id = pageObserver.doc_id;
44
+ return result;
45
+ }
46
+ function snapshotStore() {
47
+ if (
48
+ pageObserver &&
49
+ typeof pageObserver.recordSnapshot === 'function' &&
50
+ typeof pageObserver.getSnapshot === 'function'
51
+ ) {
52
+ return pageObserver;
53
+ }
54
+ return null;
55
+ }
56
+ `;
11
57
  export function buildSnapshotJs(opts = {}) {
12
58
  const optsJson = JSON.stringify({
13
59
  withinSelector: typeof opts.within_selector === 'string' ? opts.within_selector : null,
@@ -16,23 +62,25 @@ export function buildSnapshotJs(opts = {}) {
16
62
  maxInteractive: typeof opts.max_interactive === 'number' && opts.max_interactive > 0
17
63
  ? Math.min(opts.max_interactive, 500)
18
64
  : 120,
65
+ since: typeof opts.since === 'string' ? opts.since : null,
19
66
  });
20
67
  return `
21
68
  (() => {
22
69
  ${DEEP_QUERY_JS}
23
70
  ${DOM_HELPERS_JS}
71
+ ${PAGE_OBSERVER_JS}
24
72
  const opts = ${optsJson};
25
73
  let root = document;
26
74
  let notice = '';
27
75
  if (opts.withinSelector) {
28
76
  const sub = deepQueryFirst(opts.withinSelector);
29
77
  if (!sub) {
30
- return {
78
+ return withDocId({
31
79
  title: document.title,
32
80
  url: location.href,
33
81
  interactive: [],
34
82
  notice: 'within_selector did not match any element',
35
- };
83
+ });
36
84
  }
37
85
  root = sub;
38
86
  }
@@ -75,6 +123,8 @@ export function buildSnapshotJs(opts = {}) {
75
123
  const role = el.getAttribute('role') || tag;
76
124
  const selInfo = genSelectorInfo(el);
77
125
  const entry = { tag, role, selector: selInfo.selector };
126
+ const ref = refOf(el);
127
+ if (ref) entry.ref = ref;
78
128
  if (selInfo.ambiguous) entry.ambiguous = true;
79
129
  const txt = shortText(el);
80
130
  if (txt) entry.text = txt;
@@ -94,26 +144,105 @@ export function buildSnapshotJs(opts = {}) {
94
144
  if (frame) entry.frame = frame;
95
145
  interactive.push(entry);
96
146
  });
97
- const result = {
98
- title: document.title,
99
- url: location.href,
100
- interactive: interactive.slice(0, opts.maxInteractive),
101
- };
147
+ let headings = null;
148
+ let visibleText = '';
102
149
  if (!opts.onlyInteractive) {
103
- const headings = [];
150
+ const found = [];
104
151
  deepQueryAll('h1, h2, h3', root).forEach((h) => {
105
152
  if (!isVisible(h)) return;
106
153
  if (inExcludedLandmark(h)) return;
107
154
  const t = shortText(h);
108
- if (t) headings.push({ level: h.tagName, text: t });
155
+ if (t) found.push({ level: h.tagName, text: t });
109
156
  });
110
- result.headings = headings.slice(0, 30);
157
+ headings = found.slice(0, 30);
111
158
  const textRoot = (root === document) ? (document.body || document) : root;
112
- const visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
159
+ visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
160
+ }
161
+ let snapshotId = null;
162
+ if (withDocId({}).doc_id && typeof pageObserver.nextSnapshotId === 'function') {
163
+ const issued = pageObserver.nextSnapshotId();
164
+ if (typeof issued === 'string') snapshotId = issued;
165
+ }
166
+ function fullResult() {
167
+ const result = {
168
+ title: document.title,
169
+ url: location.href,
170
+ interactive: interactive.slice(0, opts.maxInteractive),
171
+ };
172
+ if (headings) result.headings = headings;
113
173
  if (visibleText) result.text = visibleText;
174
+ if (notice) result.notice = notice;
175
+ withDocId(result);
176
+ if (snapshotId) result.snapshot_id = snapshotId;
177
+ return result;
114
178
  }
115
- if (notice) result.notice = notice;
116
- return result;
179
+ // Record every snapshot (with or without since) so a later call can diff
180
+ // against it. Entries are keyed by ref; the hash is the entry serialised
181
+ // without its ref, in emission order. Capped at the first 500 entries.
182
+ const store = snapshotId ? snapshotStore() : null;
183
+ // A snapshot_id is \`<doc_id>:s<N>\`: a since whose prefix (before the LAST
184
+ // ':') is not this document's doc_id, or that has no ':', came from another
185
+ // document and is never looked up.
186
+ const sinceCut = opts.since === null ? -1 : opts.since.lastIndexOf(':');
187
+ const sinceSameDoc =
188
+ sinceCut >= 0 && withDocId({}).doc_id === opts.since.slice(0, sinceCut);
189
+ const previous = store && sinceSameDoc ? store.getSnapshot(opts.since) : null;
190
+ const current = new Map();
191
+ const headingsHash = headings ? JSON.stringify(headings) : null;
192
+ const textHash = opts.onlyInteractive ? null : visibleText;
193
+ if (store) {
194
+ for (const entry of interactive) {
195
+ if (current.size >= 500) break;
196
+ if (typeof entry.ref !== 'string') continue;
197
+ const copy = {};
198
+ for (const key of Object.keys(entry)) if (key !== 'ref') copy[key] = entry[key];
199
+ current.set(entry.ref, { hash: JSON.stringify(copy), entry });
200
+ }
201
+ store.recordSnapshot(snapshotId, { entries: current, headings_hash: headingsHash, text_hash: textHash });
202
+ }
203
+ if (opts.since === null) return fullResult();
204
+ if (!store) {
205
+ const result = fullResult();
206
+ result.since = opts.since;
207
+ result.since_status = 'observer-unavailable';
208
+ return result;
209
+ }
210
+ if (!previous || !previous.entries || typeof previous.entries.get !== 'function') {
211
+ const result = fullResult();
212
+ result.since = opts.since;
213
+ result.since_status = sinceSameDoc ? 'evicted' : 'doc-changed';
214
+ return result;
215
+ }
216
+ const added = [];
217
+ const changed = [];
218
+ const removed = [];
219
+ current.forEach((item, ref) => {
220
+ const old = previous.entries.get(ref);
221
+ if (!old) added.push(item.entry);
222
+ else if (old.hash !== item.hash) changed.push(item.entry);
223
+ });
224
+ previous.entries.forEach((item, ref) => {
225
+ if (current.has(ref) || removed.length >= 500) return;
226
+ const gone = { ref, tag: item.entry.tag };
227
+ if (item.entry.text) gone.text = item.entry.text;
228
+ removed.push(gone);
229
+ });
230
+ // max_interactive applies after diffing (added first, then changed), so
231
+ // a window shift never masquerades as change.
232
+ const listed = added.concat(changed);
233
+ const kept = listed.slice(0, opts.maxInteractive);
234
+ const diff = withDocId({ title: document.title, url: location.href });
235
+ diff.snapshot_id = snapshotId;
236
+ diff.since = opts.since;
237
+ diff.added = kept.slice(0, Math.min(added.length, kept.length));
238
+ diff.removed = removed;
239
+ diff.changed = kept.slice(diff.added.length);
240
+ if (listed.length > kept.length) diff.overflow = listed.length - kept.length;
241
+ if (!opts.onlyInteractive) {
242
+ if (headingsHash !== previous.headings_hash) diff.headings = headings;
243
+ if (textHash !== previous.text_hash) diff.text = visibleText;
244
+ }
245
+ return diff;
117
246
  })()
118
247
  `;
119
248
  }
@@ -129,6 +258,7 @@ export function buildFindJs(opts) {
129
258
  (() => {
130
259
  ${DEEP_QUERY_JS}
131
260
  ${DOM_HELPERS_JS}
261
+ ${PAGE_OBSERVER_JS}
132
262
  const opts = ${optsJson};
133
263
  const needle = opts.text.toLowerCase();
134
264
  const needleTokens = needle.split(/[^a-z0-9]+/).filter(Boolean);
@@ -189,6 +319,8 @@ export function buildFindJs(opts) {
189
319
  }
190
320
  function toNearMiss(el) {
191
321
  const entry = { text: snippetOf(el), selector: genSelector(el) };
322
+ const ref = refOf(el);
323
+ if (ref) entry.ref = ref;
192
324
  const roleAttr = el.getAttribute('role');
193
325
  if (roleAttr) entry.role = roleAttr;
194
326
  return entry;
@@ -221,44 +353,46 @@ export function buildFindJs(opts) {
221
353
  ' but none with role "' + opts.role + '"' +
222
354
  (closest ? ' — closest: ' + describeForError(closest) + ' "' + snippetOf(closest) + '"' : '');
223
355
  result.near_misses = ranked.map(toNearMiss);
224
- return result;
356
+ return withDocId(result);
225
357
  }
226
358
  const ranked = rankCandidates(broadAll);
227
359
  if (ranked.length > 0) result.near_misses = ranked.map(toNearMiss);
228
- return result;
360
+ return withDocId(result);
229
361
  }
230
362
  const ranked = rankCandidates(all);
231
363
  if (ranked.length > 0) result.near_misses = ranked.map(toNearMiss);
232
- return result;
364
+ return withDocId(result);
233
365
  }
234
366
  if (matches.length > 1) {
235
- return {
367
+ return withDocId({
236
368
  matched: false,
237
369
  reason: 'multiple-matches',
238
- candidates: matches.slice(0, opts.candidateLimit).map((el) => ({
239
- selector: genSelector(el),
240
- text: shortText(el),
241
- tag: el.tagName.toLowerCase(),
242
- })),
243
- };
370
+ candidates: matches.slice(0, opts.candidateLimit).map((el) => {
371
+ const candidate = { selector: genSelector(el) };
372
+ const ref = refOf(el);
373
+ if (ref) candidate.ref = ref;
374
+ candidate.text = shortText(el);
375
+ candidate.tag = el.tagName.toLowerCase();
376
+ return candidate;
377
+ }),
378
+ });
244
379
  }
245
380
  const el = matches[0];
246
381
  const center = viewportCenterOf(el);
247
382
  const selInfo = genSelectorInfo(el);
248
- const result = {
249
- matched: true,
250
- selector: selInfo.selector,
251
- coords: {
252
- x: Math.round(center.x),
253
- y: Math.round(center.y),
254
- },
255
- tag: el.tagName.toLowerCase(),
256
- text: shortText(el),
383
+ const result = { matched: true, selector: selInfo.selector };
384
+ const ref = refOf(el);
385
+ if (ref) result.ref = ref;
386
+ result.coords = {
387
+ x: Math.round(center.x),
388
+ y: Math.round(center.y),
257
389
  };
390
+ result.tag = el.tagName.toLowerCase();
391
+ result.text = shortText(el);
258
392
  if (selInfo.ambiguous) result.ambiguous = true;
259
393
  const frame = frameSelectorFor(el);
260
394
  if (frame) result.frame = frame;
261
- return result;
395
+ return withDocId(result);
262
396
  })()
263
397
  `;
264
398
  }
@@ -623,12 +757,16 @@ export function buildExpectProbeJs(opts) {
623
757
  * `focused` beyond `<body>`, no `dialogs` when none are open, and no
624
758
  * `scroll` when the page is at its default (0,0) position — token-lean by
625
759
  * construction, matching the omit-falsy convention the other builders use.
760
+ *
761
+ * With the page observer installed (see `PAGE_OBSERVER_JS`), `focused`
762
+ * carries `ref` right after `selector` and the result carries `doc_id`.
626
763
  */
627
764
  export function buildStateJs() {
628
765
  return `
629
766
  (() => {
630
767
  ${DEEP_QUERY_JS}
631
768
  ${DOM_HELPERS_JS}
769
+ ${PAGE_OBSERVER_JS}
632
770
  function deepActiveElement() {
633
771
  let el = document.activeElement;
634
772
  let guard = 0;
@@ -689,7 +827,10 @@ export function buildStateJs() {
689
827
  const active = deepActiveElement();
690
828
  if (active && active !== document.body && active.tagName) {
691
829
  const selInfo = genSelectorInfo(active);
692
- const focused = { selector: selInfo.selector, tag: active.tagName.toLowerCase() };
830
+ const focused = { selector: selInfo.selector };
831
+ const ref = refOf(active);
832
+ if (ref) focused.ref = ref;
833
+ focused.tag = active.tagName.toLowerCase();
693
834
  if (selInfo.ambiguous) focused.ambiguous = true;
694
835
  result.focused = focused;
695
836
  }
@@ -709,7 +850,7 @@ export function buildStateJs() {
709
850
  const scrollY = Math.round(window.scrollY);
710
851
  if (scrollX !== 0 || scrollY !== 0) result.scroll = { x: scrollX, y: scrollY };
711
852
 
712
- return result;
853
+ return withDocId(result);
713
854
  })()
714
855
  `;
715
856
  }
@@ -1 +1 @@
1
- {"version":3,"file":"builders.js","sourceRoot":"","sources":["../../src/inpage/builders.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"builders.js","sourceRoot":"","sources":["../../src/inpage/builders.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;;;yBAGP,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BvD,CAAC;AA0DF,MAAM,UAAU,eAAe,CAAC,OAAqB,EAAE;IACrD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,cAAc,EAAE,OAAO,IAAI,CAAC,eAAe,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI;QACtF,eAAe,EAAE,IAAI,CAAC,gBAAgB,KAAK,IAAI;QAC/C,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE;QACxD,cAAc,EACZ,OAAO,IAAI,CAAC,eAAe,KAAK,QAAQ,IAAI,IAAI,CAAC,eAAe,GAAG,CAAC;YAClE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,EAAE,GAAG,CAAC;YACrC,CAAC,CAAC,GAAG;QACT,KAAK,EAAE,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;KAC1D,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;IACb,cAAc;IACd,gBAAgB;iBACH,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+KxB,CAAC;AACF,CAAC;AA2CD,MAAM,UAAU,WAAW,CAAC,IAAc;IACxC,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI,EAAE,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI;QAC9E,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK,IAAI;QAC1B,cAAc,EAAE,CAAC;QACjB,aAAa,EAAE,CAAC;KACjB,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;IACb,cAAc;IACd,gBAAgB;iBACH,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuIxB,CAAC;AACF,CAAC;AAiCD,MAAM,UAAU,mBAAmB,CAAC,IAAsB;IACxD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAChF,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;CAwBxB,CAAC;AACF,CAAC;AAqBD,MAAM,UAAU,kBAAkB,CAAC,IAAqB;IACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC7D,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;;;;;IASrB,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,gGAAgG,CAAC,CAAC,CAAC,EAAE;;KAEjH,CAAC;AACN,CAAC;AAeD,MAAM,UAAU,YAAY,CAAC,IAAsB;IACjD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC7D,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;KAKpB,CAAC;AACN,CAAC;AAoBD,MAAM,UAAU,sBAAsB,CACpC,QAAgB,EAChB,SAAmC;IAEnC,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;IACzD,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;;;;;;;;;KAapB,CAAC;AACN,CAAC;AAiBD,MAAM,UAAU,kBAAkB,CAAC,IAAqB;IACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC7D,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAuCpB,CAAC;AACN,CAAC;AAwCD,MAAM,UAAU,gBAAgB,CAAC,IAAmB;IAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,aAAa,EAAE,IAAI,CAAC,aAAa;QACjC,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI,EAAE,IAAI,CAAC,IAAI;KAChB,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;cACH,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDrB,CAAC;AACF,CAAC;AA8BD,MAAM,UAAU,aAAa,CAAC,IAAgB;IAC5C,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC;QACrC,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,iBAAiB,CAAC;KAC/C,CAAC,CAAC;IACH,OAAO;;iBAEQ,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA6CpB,CAAC;AACN,CAAC;AAQD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAqB;IACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,eAAe,EAAE,IAAI,CAAC,gBAAgB;QACtC,kBAAkB,EAAE,IAAI,CAAC,mBAAmB;QAC5C,WAAW,EAAE,IAAI,CAAC,YAAY;KAC/B,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;IACb,cAAc;iBACD,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4DxB,CAAC;AACF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,YAAY;IAC1B,OAAO;;IAEL,aAAa;IACb,cAAc;IACd,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsFnB,CAAC;AACF,CAAC"}
@@ -22,7 +22,7 @@
22
22
  * `cdp/builders-parity.test.ts` checks the built output.
23
23
  */
24
24
  /** Bump whenever the in-page protocol (global shape, messages, methods) changes. */
25
- export declare const OBSERVER_VERSION = 1;
25
+ export declare const OBSERVER_VERSION = 2;
26
26
  /** Name of the `Runtime.addBinding` function the observer pushes messages through. */
27
27
  export declare const OBSERVER_BINDING = "__blPageObserverPush";
28
28
  /** Well-known global that holds the installed observer instance. */
@@ -42,6 +42,19 @@ export interface ObserverInstallOpts {
42
42
  * `Page.addScriptToEvaluateOnNewDocument` source and as a one-off
43
43
  * `Runtime.evaluate` expression, which evaluates to
44
44
  * `{ ok: true, installed, version, doc_id, coverage }`.
45
+ *
46
+ * The instance also owns element identity (spec v4 §5.6): `refFor(el)` issues
47
+ * `e<N>` (monotonic per document instance, from `e1`) the first time an
48
+ * element is seen and reuses it while the node lives; `resolveRef(ref)`
49
+ * returns the element while it is connected, else `null`. Refs are only valid
50
+ * for the `doc_id` that issued them. Neither method throws. `nextSnapshotId()`
51
+ * issues `<doc_id>:s<N>` (N monotonic per document instance, from 1) for each
52
+ * `snapshot` result and returns `null` once the instance is torn down; the
53
+ * `doc_id` prefix lets a stale id from another document be rejected.
54
+ * `recordSnapshot(id, record)` keeps the last 4 snapshot records per document
55
+ * instance (oldest evicted first) for `snapshot({ since })`; `getSnapshot(id)`
56
+ * returns the record or `null` when unknown or evicted. The record is opaque
57
+ * here. Both are inert after teardown, which clears the store.
45
58
  */
46
59
  export declare function buildObserverInstallJs(opts?: ObserverInstallOpts): string;
47
60
  /**
@@ -60,6 +73,13 @@ export declare function buildObserverMarkJs(): string;
60
73
  * baseline; or `{ ok: false, reason: 'observer-unavailable' | 'doc-changed' }`.
61
74
  */
62
75
  export declare function buildObserverChangesJs(mark: number, docId?: string): string;
76
+ /**
77
+ * Build the ref-resolve expression, for tests and debugging. Evaluates to
78
+ * `{ ok: true, found: true, tag }` when `ref` names an element of the current
79
+ * document that is still connected, `{ ok: true, found: false }` when it is
80
+ * unknown or stale, or `{ ok: false, reason: 'observer-unavailable' }`.
81
+ */
82
+ export declare function buildObserverRefResolveJs(ref: string): string;
63
83
  /**
64
84
  * Build the teardown expression: disconnects every observer, restores the
65
85
  * `attachShadow` and `history` patches when they are still the observer's,
@@ -22,7 +22,7 @@
22
22
  * `cdp/builders-parity.test.ts` checks the built output.
23
23
  */
24
24
  /** Bump whenever the in-page protocol (global shape, messages, methods) changes. */
25
- export const OBSERVER_VERSION = 1;
25
+ export const OBSERVER_VERSION = 2;
26
26
  /** Name of the `Runtime.addBinding` function the observer pushes messages through. */
27
27
  export const OBSERVER_BINDING = '__blPageObserverPush';
28
28
  /** Well-known global that holds the installed observer instance. */
@@ -41,6 +41,19 @@ function clampInt(value, fallback, min, max) {
41
41
  * `Page.addScriptToEvaluateOnNewDocument` source and as a one-off
42
42
  * `Runtime.evaluate` expression, which evaluates to
43
43
  * `{ ok: true, installed, version, doc_id, coverage }`.
44
+ *
45
+ * The instance also owns element identity (spec v4 §5.6): `refFor(el)` issues
46
+ * `e<N>` (monotonic per document instance, from `e1`) the first time an
47
+ * element is seen and reuses it while the node lives; `resolveRef(ref)`
48
+ * returns the element while it is connected, else `null`. Refs are only valid
49
+ * for the `doc_id` that issued them. Neither method throws. `nextSnapshotId()`
50
+ * issues `<doc_id>:s<N>` (N monotonic per document instance, from 1) for each
51
+ * `snapshot` result and returns `null` once the instance is torn down; the
52
+ * `doc_id` prefix lets a stale id from another document be rejected.
53
+ * `recordSnapshot(id, record)` keeps the last 4 snapshot records per document
54
+ * instance (oldest evicted first) for `snapshot({ since })`; `getSnapshot(id)`
55
+ * returns the record or `null` when unknown or evicted. The record is opaque
56
+ * here. Both are inert after teardown, which clears the store.
44
57
  */
45
58
  export function buildObserverInstallJs(opts = {}) {
46
59
  const optsJson = JSON.stringify({
@@ -82,6 +95,18 @@ export function buildObserverInstallJs(opts = {}) {
82
95
  };
83
96
  const observers = [];
84
97
  const observed = new WeakSet();
98
+ let refs = new WeakMap();
99
+ const refNodes = new Map();
100
+ let refSeq = 0;
101
+ let snapshotSeq = 0;
102
+ // Last SNAPSHOT_KEEP snapshot records (opaque to the observer; the snapshot
103
+ // builder owns their shape), keyed by snapshot_id in insertion order.
104
+ const SNAPSHOT_KEEP = 4;
105
+ const snapshots = new Map();
106
+ // WeakRef keeps the registry from pinning removed nodes; the fallback only
107
+ // matters on engines without it (Chrome has it since 84).
108
+ const holdWeakly =
109
+ typeof WeakRef === 'function' ? (el) => new WeakRef(el) : (el) => ({ deref: () => el });
85
110
  const round = (n) => Math.round(n * 10) / 10;
86
111
 
87
112
  function push(message) {
@@ -268,6 +293,53 @@ export function buildObserverInstallJs(opts = {}) {
268
293
 
269
294
  g.flush = flush;
270
295
 
296
+ g.refFor = function (el) {
297
+ try {
298
+ if (g.dead || !el || el.nodeType !== 1) return null;
299
+ let ref = refs.get(el);
300
+ if (!ref) {
301
+ ref = 'e' + ++refSeq;
302
+ refs.set(el, ref);
303
+ }
304
+ // Same node, same ref: re-register one pruned while it was detached.
305
+ if (!refNodes.has(ref)) refNodes.set(ref, holdWeakly(el));
306
+ return ref;
307
+ } catch (e) {
308
+ return null;
309
+ }
310
+ };
311
+
312
+ g.resolveRef = function (ref) {
313
+ try {
314
+ if (typeof ref !== 'string') return null;
315
+ const held = refNodes.get(ref);
316
+ if (!held) return null;
317
+ const el = held.deref();
318
+ if (el && el.isConnected) return el;
319
+ refNodes.delete(ref);
320
+ return null;
321
+ } catch (e) {
322
+ return null;
323
+ }
324
+ };
325
+
326
+ g.nextSnapshotId = function () {
327
+ if (g.dead) return null;
328
+ return g.doc_id + ':s' + ++snapshotSeq;
329
+ };
330
+ g.recordSnapshot = function (id, record) {
331
+ if (g.dead || typeof id !== 'string') return;
332
+ snapshots.delete(id);
333
+ snapshots.set(id, record);
334
+ while (snapshots.size > SNAPSHOT_KEEP) snapshots.delete(snapshots.keys().next().value);
335
+ };
336
+
337
+ g.getSnapshot = function (id) {
338
+ if (g.dead || typeof id !== 'string') return null;
339
+ const record = snapshots.get(id);
340
+ return record === undefined ? null : record;
341
+ };
342
+
271
343
  g.mark = function () {
272
344
  flush();
273
345
  const mark = g.seq;
@@ -348,6 +420,10 @@ export function buildObserverInstallJs(opts = {}) {
348
420
  window.removeEventListener('popstate', onNav);
349
421
  window.removeEventListener('hashchange', onNav);
350
422
  g.baselines.clear();
423
+ refs = new WeakMap();
424
+ refNodes.clear();
425
+ snapshotSeq = 0;
426
+ snapshots.clear();
351
427
  if (window[opts.key] === g) {
352
428
  try { delete window[opts.key]; } catch (e) {}
353
429
  }
@@ -390,9 +466,37 @@ export function buildObserverMarkJs() {
390
466
  */
391
467
  export function buildObserverChangesJs(mark, docId) {
392
468
  const markJson = JSON.stringify(clampInt(mark, 0, 0, Number.MAX_SAFE_INTEGER));
393
- const docJson = typeof docId === 'string' && docId.length > 0 ? JSON.stringify(docId) : 'null';
469
+ const docJson = typeof docId === 'string' && docId.length > 0 ? jsStringLiteral(docId) : 'null';
394
470
  return callInstance(`g.changes(${markJson}, ${docJson})`);
395
471
  }
472
+ /**
473
+ * A string literal safe to embed in JS source. `JSON.stringify` alone leaves
474
+ * U+2028 / U+2029 (line terminators in older JS grammars) and `<` (which can
475
+ * close a surrounding `<script>`) unescaped; this escapes them as `\uXXXX`,
476
+ * which parses back to the same string.
477
+ */
478
+ function jsStringLiteral(value) {
479
+ // The two separators are written as char codes so this source file never
480
+ // carries them raw; the replacement is the six-character escape sequence.
481
+ const lineSeparator = String.fromCharCode(0x2028);
482
+ const paragraphSeparator = String.fromCharCode(0x2029);
483
+ return JSON.stringify(value)
484
+ .split(lineSeparator)
485
+ .join('\\u2028')
486
+ .split(paragraphSeparator)
487
+ .join('\\u2029')
488
+ .replace(/</g, '\\u003c');
489
+ }
490
+ /**
491
+ * Build the ref-resolve expression, for tests and debugging. Evaluates to
492
+ * `{ ok: true, found: true, tag }` when `ref` names an element of the current
493
+ * document that is still connected, `{ ok: true, found: false }` when it is
494
+ * unknown or stale, or `{ ok: false, reason: 'observer-unavailable' }`.
495
+ */
496
+ export function buildObserverRefResolveJs(ref) {
497
+ const refJson = jsStringLiteral(typeof ref === 'string' ? ref : '');
498
+ return callInstance(`((el) => el ? { ok: true, found: true, tag: el.localName || String(el.nodeName).toLowerCase() } : { ok: true, found: false })(g.resolveRef(${refJson}))`);
499
+ }
396
500
  /**
397
501
  * Build the teardown expression: disconnects every observer, restores the
398
502
  * `attachShadow` and `history` patches when they are still the observer's,
@@ -1 +1 @@
1
- {"version":3,"file":"observer.js","sourceRoot":"","sources":["../../src/inpage/observer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"observer.js","sourceRoot":"","sources":["../../src/inpage/observer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,oFAAoF;AACpF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAElC,sFAAsF;AACtF,MAAM,CAAC,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AAEvD,oEAAoE;AACpE,MAAM,CAAC,MAAM,eAAe,GAAG,kBAAkB,CAAC;AAElD,MAAM,sBAAsB,GAAG,EAAE,CAAC;AAClC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAC/B,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAC7B,MAAM,aAAa,GAAG,IAAI,CAAC;AAa3B,SAAS,QAAQ,CAAC,KAAc,EAAE,QAAgB,EAAE,GAAW,EAAE,GAAW;IAC1E,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC1E,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,sBAAsB,CAAC,OAA4B,EAAE;IACnE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,GAAG,EAAE,eAAe;QACpB,OAAO,EACL,OAAO,IAAI,CAAC,OAAO,KAAK,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB;QAC/F,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,CAAC,EAAE,MAAM,CAAC,gBAAgB,CAAC;QAC7E,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,cAAc,EAAE,sBAAsB,EAAE,CAAC,EAAE,kBAAkB,CAAC;QACjF,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,iBAAiB,EAAE,CAAC,EAAE,aAAa,CAAC;KACxE,CAAC,CAAC;IACH,OAAO;;iBAEQ,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA+WpB,CAAC;AACN,CAAC;AAED,MAAM,WAAW,GAAG,+CAA+C,CAAC;AAEpE,SAAS,YAAY,CAAC,IAAY;IAChC,OAAO;;qBAEY,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC;6DACS,WAAW;;aAE3D,IAAI;;aAEJ,WAAW;;KAEnB,CAAC;AACN,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,YAAY,CAAC,UAAU,CAAC,CAAC;AAClC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAAY,EAAE,KAAc;IACjE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC;IAC/E,MAAM,OAAO,GAAG,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IAChG,OAAO,YAAY,CAAC,aAAa,QAAQ,KAAK,OAAO,GAAG,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;GAKG;AACH,SAAS,eAAe,CAAC,KAAa;IACpC,yEAAyE;IACzE,0EAA0E;IAC1E,MAAM,aAAa,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IAClD,MAAM,kBAAkB,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IACvD,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;SACzB,KAAK,CAAC,aAAa,CAAC;SACpB,IAAI,CAAC,SAAS,CAAC;SACf,KAAK,CAAC,kBAAkB,CAAC;SACzB,IAAI,CAAC,SAAS,CAAC;SACf,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;AAC9B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB,CAAC,GAAW;IACnD,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACpE,OAAO,YAAY,CACjB,8IAA8I,OAAO,IAAI,CAC1J,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO;;gBAEO,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC;;;;;;;;;;KAU1C,CAAC;AACN,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "browser-link",
4
- "version": "0.35.0",
4
+ "version": "0.36.0",
5
5
  "description": "Bridge between Chrome and an MCP client (Claude Code, OpenCode, GitHub Copilot CLI, …). Per-tab manual activation.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -188,7 +188,7 @@ export const BROWSER_TOOL_DEFINITIONS = [
188
188
  },
189
189
  {
190
190
  name: 'browser.snapshot',
191
- description: 'Snapshot of the tab: title, url, visible text (truncated) and a list of interactive elements (buttons, links, inputs, selects, textareas) with a CSS selector and labels. Use this to understand page state before clicking or typing. Elements whose computed `pointer-events` is `none` (invisible accessibility layers that can never receive a real click) are excluded from the interactive list, though their text still contributes to headings/text; a descendant that re-enables itself with `pointer-events:auto` is still listed. The scan pierces OPEN Shadow DOM roots and same-origin iframes (nested arbitrarily) — a web component internal or an in-page iframe shows up without extra steps. An entry for an element living inside an iframe carries an extra `frame` field (the CSS selector of the innermost hosting iframe) so you know it is framed; entries in the top document or in Shadow DOM omit it. An entry whose selector could not be made unique across all roots (structurally-identical component twins) carries `ambiguous: true` — that selector resolves first-match-wins, so use it immediately and do NOT store it in the persistent map. Optional filters keep the response small: `within_selector` restricts the scan to a subtree (also deep-search-aware — it can target a subtree inside a shadow root or iframe); `only_interactive` skips headings and the text dump; `exclude` drops landmarks like nav/footer; `max_interactive` overrides the default cap of 120. The per-entry serializer omits empty-string fields, so the same call returns a leaner payload than before — no behavior change for clients that read by key.',
191
+ description: 'Snapshot of the tab: title, url, visible text (truncated) and a list of interactive elements (buttons, links, inputs, selects, textareas) with a CSS selector and labels. Use this to understand page state before clicking or typing. Elements whose computed `pointer-events` is `none` (invisible accessibility layers that can never receive a real click) are excluded from the interactive list, though their text still contributes to headings/text; a descendant that re-enables itself with `pointer-events:auto` is still listed. The scan pierces OPEN Shadow DOM roots and same-origin iframes (nested arbitrarily) — a web component internal or an in-page iframe shows up without extra steps. An entry for an element living inside an iframe carries an extra `frame` field (the CSS selector of the innermost hosting iframe) so you know it is framed; entries in the top document or in Shadow DOM omit it. An entry whose selector could not be made unique across all roots (structurally-identical component twins) carries `ambiguous: true` — that selector resolves first-match-wins, so use it immediately and do NOT store it in the persistent map. Optional filters keep the response small: `within_selector` restricts the scan to a subtree (also deep-search-aware — it can target a subtree inside a shadow root or iframe); `only_interactive` skips headings and the text dump; `exclude` drops landmarks like nav/footer; `max_interactive` overrides the default cap of 120. The per-entry serializer omits empty-string fields, so the same call returns a leaner payload than before — no behavior change for clients that read by key. When the page observer is running in the tab, each interactive entry also carries `ref` (right after `selector`): a per-document element handle `e<N>` issued by the page observer, stable for the same element across snapshot/find/state calls and valid only for the `doc_id` that issued it; unlike `ambiguous` (a selector-level warning), a `ref` names exactly one element of the current document. The result then also carries `doc_id` (the document instance that issued the refs; it changes on every new document) and `snapshot_id` (`<doc_id>:s<N>`, one per snapshot call in that document). All three are additive and ignorable, and are absent when the observer is not in the page. Pass `since: <snapshot_id>` to get a diff instead of the whole page: `{ title, url, doc_id, snapshot_id, since, added, removed, changed, overflow? }`, where `added` and `changed` are full entries (added first) and `removed` is `{ ref, tag, text? }`. `max_interactive` applies after diffing to `added` + `changed` and `overflow` (count dropped) appears only when above 0; `headings` and `text` appear in a diff only when `only_interactive` is false and they changed. When no diff can be made the result is the full snapshot plus `since` and `since_status`: `doc-changed` (the `since` prefix is not the current `doc_id`, or it has no `:`), `evicted` (same document, but not among the last 4 snapshots or never issued) or `observer-unavailable`. An invalid `since` (not a string, or over 64 characters) is dropped and a plain full snapshot is returned; a `within_selector` miss ignores `since`.',
192
192
  inputSchema: {
193
193
  type: 'object',
194
194
  properties: {
@@ -210,6 +210,11 @@ export const BROWSER_TOOL_DEFINITIONS = [
210
210
  type: 'number',
211
211
  description: 'Cap on the number of interactive entries returned. Default 120, hard ceiling 500.',
212
212
  },
213
+ since: {
214
+ type: 'string',
215
+ maxLength: 64,
216
+ description: 'The `snapshot_id` of an earlier snapshot of this document. Format `<doc_id>:s<N>`. Returns only `added`, `removed` and `changed` entries (by `ref`) against it; when it cannot diff, returns the full snapshot plus `since_status` (`evicted`, `doc-changed` or `observer-unavailable`). The observer keeps the last 4 snapshots per document.',
217
+ },
213
218
  },
214
219
  required: ['tab_id'],
215
220
  additionalProperties: false,
@@ -221,6 +226,7 @@ export const BROWSER_TOOL_DEFINITIONS = [
221
226
  'Before clicking or typing, to find a stable selector for the target element.',
222
227
  'When the user reports a layout or visual issue and you need to ground your reasoning in what is actually rendered.',
223
228
  'When you only care about a region of the page, pass `within_selector` so the response stays small.',
229
+ 'When you look at the same view again: take one full snapshot, keep its `snapshot_id`, and pass it as `since` on every later look.',
224
230
  ],
225
231
  gotchas: [
226
232
  'The snapshot is the source of truth; the persistent map is a cache, not a substitute.',
@@ -228,13 +234,17 @@ export const BROWSER_TOOL_DEFINITIONS = [
228
234
  'Empty-string fields (`placeholder`, `aria_label`, etc.) are omitted from each entry. Read by key with optional-chaining or fall back to "".',
229
235
  'CLOSED shadow roots (attachShadow({mode:"closed"})) are unreachable from any CDP-based tool — there is no workaround. Cross-origin iframes are also unreachable (same-origin policy); their content is invisible to this scan.',
230
236
  'Two structurally-identical component instances in different roots (e.g. twin web components with byte-identical internals) cannot be told apart by any CSS selector — no syntax scopes a selector to one shadow root. Affected entries carry `ambiguous: true`; their selector resolves to the FIRST match in a deterministic traversal order. Use it right away, never cache it.',
237
+ '`ref`, `doc_id` and `snapshot_id` appear only when the page observer is running in the tab. A `ref` dies with its document: compare `doc_id` before trusting a ref from an earlier call, and never store refs in the persistent map. `ref` is informational for now: it is not yet accepted as an action target.',
238
+ 'With `since`, a `since_status` of `doc-changed`, `evicted` or `observer-unavailable` means you already received the full snapshot: keep its new `snapshot_id` for the next `since`. Do not diff against the old id again.',
239
+ 'The structural selector adds `:nth-of-type` only when siblings force it, so inserting a sibling can change the selector of a neighbour and list that neighbour in `changed`. The selector did change: use the current one, not the one from the earlier snapshot.',
240
+ 'A diff covers at most the first 500 interactive entries of each snapshot, and `removed` is capped at 500.',
231
241
  ],
232
242
  example: 'browser.snapshot({ tab_id: "tab_1", within_selector: "main", exclude: ["nav", "footer"] })',
233
243
  },
234
244
  },
235
245
  {
236
246
  name: 'browser.find',
237
- description: 'Locate ONE interactive element by its visible text and return a stable selector plus viewport coordinates. The match is case-insensitive substring by default (set `exact:true` for full-string equality). Pass `role` to narrow to a specific ARIA role (`button`, `link`, `textbox`, `checkbox`, `tab`, `menuitem`) — without it the scan covers buttons, links, inputs, role-bearing elements, contenteditable nodes, and `[onclick]` divs (the "peruvian markup" case). Elements whose computed `pointer-events` is `none` (invisible accessibility layers that can never receive a real click) are never returned as matches, though they can still surface as `near_misses` hints. The scan pierces OPEN Shadow DOM roots and same-origin iframes, same as browser.snapshot. Returns `{ matched: true, selector, coords:{x,y}, tag, text, frame?, ambiguous? }` on a unique hit, `{ matched: false, reason: "not-found", error?, near_misses? }` when nothing matches, or `{ matched: false, reason: "multiple-matches", candidates: [{selector, text, tag}] }` (up to 5) when several elements match — pick one and try again with `exact:true` or a longer text. On `not-found`, `near_misses` carries up to 3 VISIBLE candidates (`{text, selector, role?}`) ranked by substring containment then token overlap — omitted entirely when nothing plausible exists. When `role` excluded matches that the text DID find elsewhere, `error` names it explicitly (e.g. `text matched 2 elements but none with role "button" — closest: <div onclick> "GIF picker"`). `frame`, when present, is the CSS selector of the innermost iframe hosting the match. `ambiguous: true`, when present, means the selector could not be made unique across all roots (structurally-identical twins) and resolves first-match-wins — use it immediately, never cache it. `coords` are already mapped to TOP-LEVEL viewport coordinates even when the match lives inside an iframe. The returned `selector` uses the same heuristic as `browser.snapshot` (id → data-testid → aria-label → name → positional fallback, each verified unique across the full deep search scope), so a subsequent `browser.click` / `browser.type` works without re-querying.',
247
+ description: 'Locate ONE interactive element by its visible text and return a stable selector plus viewport coordinates. The match is case-insensitive substring by default (set `exact:true` for full-string equality). Pass `role` to narrow to a specific ARIA role (`button`, `link`, `textbox`, `checkbox`, `tab`, `menuitem`) — without it the scan covers buttons, links, inputs, role-bearing elements, contenteditable nodes, and `[onclick]` divs (the "peruvian markup" case). Elements whose computed `pointer-events` is `none` (invisible accessibility layers that can never receive a real click) are never returned as matches, though they can still surface as `near_misses` hints. The scan pierces OPEN Shadow DOM roots and same-origin iframes, same as browser.snapshot. Returns `{ matched: true, selector, ref?, coords:{x,y}, tag, text, frame?, ambiguous?, doc_id? }` on a unique hit, `{ matched: false, reason: "not-found", error?, near_misses?, doc_id? }` when nothing matches, or `{ matched: false, reason: "multiple-matches", candidates: [{selector, ref?, text, tag}], doc_id? }` (up to 5) when several elements match — pick one and try again with `exact:true` or a longer text. On `not-found`, `near_misses` carries up to 3 VISIBLE candidates (`{text, selector, ref?, role?}`) ranked by substring containment then token overlap — omitted entirely when nothing plausible exists. When `role` excluded matches that the text DID find elsewhere, `error` names it explicitly (e.g. `text matched 2 elements but none with role "button" — closest: <div onclick> "GIF picker"`). `frame`, when present, is the CSS selector of the innermost iframe hosting the match. `ambiguous: true`, when present, means the selector could not be made unique across all roots (structurally-identical twins) and resolves first-match-wins — use it immediately, never cache it. `coords` are already mapped to TOP-LEVEL viewport coordinates even when the match lives inside an iframe. The returned `selector` uses the same heuristic as `browser.snapshot` (id → data-testid → aria-label → name → positional fallback, each verified unique across the full deep search scope), so a subsequent `browser.click` / `browser.type` works without re-querying. `ref` and `doc_id` are additive, ignorable fields present only when the page observer is running in the tab: `ref` is the same per-document element handle `e<N>` that browser.snapshot reports for that element, valid only for the `doc_id` that issued it.',
238
248
  inputSchema: {
239
249
  type: 'object',
240
250
  properties: {
@@ -277,7 +287,7 @@ export const BROWSER_TOOL_DEFINITIONS = [
277
287
  },
278
288
  {
279
289
  name: 'browser.state',
280
- description: 'Compact orientation snapshot — cheaper than a full browser.snapshot when the agent only needs to know where it is. Returns { url, title, viewport:{w,h}, focused?, dialogs?, scroll? }. `focused` is the innermost actually-focused element (descending through open Shadow DOM roots and same-origin iframes past `document.activeElement`, which by itself only reports a shadow host or an <iframe> element) as `{ selector, tag, ambiguous? }`. `dialogs` lists visible `[role=dialog]`, `[role=alertdialog]` and open `<dialog>` elements as `{ selector, role, label? }`, `label` best-effort from aria-label / aria-labelledby / the first heading inside. `scroll` is `{x,y}` and is omitted when the page is at its default (0,0) position. Every optional field is omitted when there is nothing to report.',
290
+ description: 'Compact orientation snapshot — cheaper than a full browser.snapshot when the agent only needs to know where it is. Returns { url, title, viewport:{w,h}, focused?, dialogs?, scroll? }. `focused` is the innermost actually-focused element (descending through open Shadow DOM roots and same-origin iframes past `document.activeElement`, which by itself only reports a shadow host or an <iframe> element) as `{ selector, ref?, tag, ambiguous? }`. `dialogs` lists visible `[role=dialog]`, `[role=alertdialog]` and open `<dialog>` elements as `{ selector, role, label? }`, `label` best-effort from aria-label / aria-labelledby / the first heading inside. `scroll` is `{x,y}` and is omitted when the page is at its default (0,0) position. Every optional field is omitted when there is nothing to report. When the page observer is running in the tab, `focused.ref` is the same per-document element handle `e<N>` that browser.snapshot reports for that element and the result carries `doc_id`; both are additive, ignorable, valid only for that `doc_id`, and absent when the observer is not in the page.',
281
291
  inputSchema: {
282
292
  type: 'object',
283
293
  properties: { tab_id: { type: 'string' } },