@reticlehq/browser 3.8.2 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/actions/action-report.d.ts +14 -0
  2. package/dist/actions/action-report.js +95 -22
  3. package/dist/actions/actions-dom.d.ts +17 -6
  4. package/dist/actions/actions-dom.js +98 -47
  5. package/dist/actions/actions-press.d.ts +10 -0
  6. package/dist/actions/actions-press.js +146 -2
  7. package/dist/actions/actions.js +64 -24
  8. package/dist/actions/insert-text.d.ts +3 -0
  9. package/dist/actions/insert-text.js +90 -0
  10. package/dist/actions/scroll.d.ts +1 -1
  11. package/dist/actions/scroll.js +25 -1
  12. package/dist/actions/value-input.d.ts +2 -0
  13. package/dist/actions/value-input.js +30 -9
  14. package/dist/commands/commands.js +85 -9
  15. package/dist/connect-options.d.ts +11 -1
  16. package/dist/connect-options.js +27 -1
  17. package/dist/dom/a11y.d.ts +1 -1
  18. package/dist/dom/a11y.js +11 -10
  19. package/dist/dom/chart-geometry.d.ts +7 -0
  20. package/dist/dom/chart-geometry.js +39 -0
  21. package/dist/dom/occlusion.js +21 -0
  22. package/dist/dom/query-finders.d.ts +1 -1
  23. package/dist/dom/query-finders.js +10 -7
  24. package/dist/dom/query.d.ts +6 -2
  25. package/dist/dom/query.js +165 -54
  26. package/dist/dom/scope-roots.d.ts +8 -0
  27. package/dist/dom/scope-roots.js +44 -0
  28. package/dist/dom/snapshot.d.ts +3 -0
  29. package/dist/dom/snapshot.js +9 -2
  30. package/dist/dom/text-match.js +6 -4
  31. package/dist/dom/theme.js +6 -7
  32. package/dist/dom/visibility.d.ts +3 -0
  33. package/dist/dom/visibility.js +125 -10
  34. package/dist/observers/canvas.d.ts +1 -0
  35. package/dist/observers/canvas.js +10 -1
  36. package/dist/observers/console.js +46 -35
  37. package/dist/observers/dom.js +28 -4
  38. package/dist/observers/net-detail/resource-timing.d.ts +12 -0
  39. package/dist/observers/net-detail/resource-timing.js +69 -0
  40. package/dist/observers/network.js +13 -44
  41. package/dist/observers/perf.js +15 -1
  42. package/dist/presenter/chrome/presenter-verbs.js +1 -0
  43. package/dist/presenter/presenter-config.d.ts +1 -0
  44. package/dist/presenter/presenter-config.js +1 -0
  45. package/dist/presenter/presenter-shell.d.ts +1 -0
  46. package/dist/presenter/presenter-shell.js +6 -3
  47. package/dist/presenter/tour/tour.js +4 -0
  48. package/dist/recorder/recorder.js +82 -39
  49. package/dist/reticle-inject.js +618 -618
  50. package/dist/reticle.d.ts +3 -19
  51. package/dist/reticle.js +76 -110
  52. package/dist/review/annotator.js +4 -0
  53. package/dist/security/serialization.js +35 -0
  54. package/dist/session-continuity.d.ts +1 -0
  55. package/dist/session-continuity.js +18 -0
  56. package/dist/storage-keys.js +2 -1
  57. package/dist/timers/native/native-timers.js +21 -4
  58. package/dist/transport/bridge-url.d.ts +8 -0
  59. package/dist/transport/bridge-url.js +52 -0
  60. package/package.json +10 -10
@@ -1,3 +1,4 @@
1
+ import { RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
1
2
  import { isHtmlElement } from './realm.js';
2
3
  import { parentAcrossShadowBoundary } from './shadow-registry.js';
3
4
  import { clippingBox, overflowAxes, scrolledInto, } from './paint-context.js';
@@ -16,17 +17,51 @@ function hiddenInsideClosedDetails(el) {
16
17
  const summary = Array.from(details.children).find((child) => HTML_SUMMARY_TAG === child.localName);
17
18
  return summary === undefined || !summary.contains(el);
18
19
  }
19
- function stalledFade(el) {
20
+ export function stalledFade(el) {
20
21
  if (!el.ownerDocument.hidden || !('getAnimations' in el)) {
21
22
  return false;
22
23
  }
23
24
  return el.getAnimations().some((a) => 'running' === a.playState || a.pending);
24
25
  }
25
26
  const OVERLAY_ARIA_HIDDEN = 'data-aria-hidden';
27
+ const OPEN_LAYER = '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], dialog[open], [aria-modal="true"]';
28
+ const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
29
+ export function isNonRendered(el) {
30
+ return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
31
+ }
32
+ const UNPAINTED_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta']);
26
33
  function hiddenByAnOverlay(el) {
27
- return el.hasAttribute(OVERLAY_ARIA_HIDDEN);
34
+ if (el.hasAttribute(OVERLAY_ARIA_HIDDEN)) {
35
+ return true;
36
+ }
37
+ return hiddenByAModalManager(el);
38
+ }
39
+ function hiddenByAModalManager(el) {
40
+ const body = el.ownerDocument.body;
41
+ if (el.parentElement !== body) {
42
+ return false;
43
+ }
44
+ const others = Array.from(body.children).filter((child) => child !== el &&
45
+ !UNPAINTED_TAGS.has(child.localName) &&
46
+ !child.matches(RETICLE_OVERLAY_SELECTOR));
47
+ const open = others.filter((child) => 'true' !== child.getAttribute('aria-hidden'));
48
+ const layer = open[0];
49
+ return 1 === open.length && layer !== undefined && holdsOpenLayer(layer);
50
+ }
51
+ function holdsOpenLayer(portal) {
52
+ const layer = portal.matches(OPEN_LAYER) ? portal : portal.querySelector(OPEN_LAYER);
53
+ for (let node = layer; null !== node; node = node.parentElement) {
54
+ const style = node.ownerDocument.defaultView?.getComputedStyle(node) ?? null;
55
+ if (selfCssHidden(node, style)) {
56
+ return false;
57
+ }
58
+ if (node === portal) {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
28
63
  }
29
- function selfCssHidden(el, style) {
64
+ function selfCssHidden(el, style, includeVisibility = true) {
30
65
  if (isHtmlElement(el) && el.hidden) {
31
66
  return true;
32
67
  }
@@ -34,9 +69,7 @@ function selfCssHidden(el, style) {
34
69
  return true;
35
70
  }
36
71
  if (style !== null) {
37
- if ('none' === style.display ||
38
- 'hidden' === style.visibility ||
39
- 'collapse' === style.visibility) {
72
+ if ('none' === style.display || (includeVisibility && visibilityHidden(style))) {
40
73
  return true;
41
74
  }
42
75
  if (0 === Number.parseFloat(style.opacity || '1') && !stalledFade(el)) {
@@ -48,6 +81,9 @@ function selfCssHidden(el, style) {
48
81
  }
49
82
  return false;
50
83
  }
84
+ function visibilityHidden(style) {
85
+ return 'hidden' === style?.visibility || 'collapse' === style?.visibility;
86
+ }
51
87
  function clippedAway(style) {
52
88
  const rect = /^rect\((.*)\)$/.exec(style.getPropertyValue('clip').trim());
53
89
  if (null !== rect && ('absolute' === style.position || 'fixed' === style.position)) {
@@ -75,7 +111,7 @@ function selfHidden(el, style) {
75
111
  if ('true' === el.getAttribute('aria-hidden') && !hiddenByAnOverlay(el)) {
76
112
  return true;
77
113
  }
78
- return selfCssHidden(el, style);
114
+ return selfCssHidden(el, style, false);
79
115
  }
80
116
  export function isHiddenByAriaOnly(el) {
81
117
  let foundAriaHidden = false;
@@ -85,7 +121,7 @@ export function isHiddenByAriaOnly(el) {
85
121
  foundAriaHidden = true;
86
122
  }
87
123
  const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
88
- if (selfCssHidden(current, style)) {
124
+ if (selfCssHidden(current, style, current === el)) {
89
125
  return false;
90
126
  }
91
127
  current = parentAcrossShadowBoundary(current);
@@ -99,13 +135,63 @@ export function isRenderedOutsideAriaHidden(el) {
99
135
  return false;
100
136
  }
101
137
  const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
102
- if (selfCssHidden(current, style)) {
138
+ if (selfCssHidden(current, style, current === el)) {
103
139
  return false;
104
140
  }
105
141
  current = parentAcrossShadowBoundary(current);
106
142
  }
107
143
  return true;
108
144
  }
145
+ const BACKFACE_VISIBILITY_HIDDEN = 'hidden';
146
+ function localTransform(style) {
147
+ if ('undefined' === typeof DOMMatrix) {
148
+ return null;
149
+ }
150
+ try {
151
+ const transform = style?.transform;
152
+ return undefined === transform || '' === transform || 'none' === transform
153
+ ? new DOMMatrix()
154
+ : new DOMMatrix(transform);
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ function backFaceTowardViewer(matrix) {
161
+ return Number.isFinite(matrix.m33) && 0 > matrix.m33;
162
+ }
163
+ function backfaceHidden(el, memo) {
164
+ const chain = [];
165
+ let declares = false;
166
+ for (let current = el; null !== current; current = parentAcrossShadowBoundary(current)) {
167
+ const style = ownVisibilityInfo(current, memo).style;
168
+ if (BACKFACE_VISIBILITY_HIDDEN === style?.backfaceVisibility) {
169
+ declares = true;
170
+ }
171
+ chain.push(current);
172
+ }
173
+ if (!declares) {
174
+ return false;
175
+ }
176
+ let composed = null;
177
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
178
+ const current = chain[index];
179
+ if (undefined === current) {
180
+ return false;
181
+ }
182
+ const style = ownVisibilityInfo(current, memo).style;
183
+ const local = localTransform(style);
184
+ if (null === local) {
185
+ return false;
186
+ }
187
+ composed = null === composed ? local : composed.multiply(local);
188
+ if (BACKFACE_VISIBILITY_HIDDEN === style?.backfaceVisibility &&
189
+ backFaceTowardViewer(composed)) {
190
+ return true;
191
+ }
192
+ }
193
+ return false;
194
+ }
109
195
  const CLIPPING_OVERFLOW = new Set(['hidden', 'clip']);
110
196
  const SCROLLING_OVERFLOW = new Set(['auto', 'scroll']);
111
197
  const NON_CLIPPING_DISPLAY = new Set([
@@ -154,9 +240,32 @@ function visibilityInfoMemo(memo) {
154
240
  }
155
241
  return info;
156
242
  }
243
+ function underKnownHidden(el, memo) {
244
+ for (let up = parentAcrossShadowBoundary(el); null !== up; up = parentAcrossShadowBoundary(up)) {
245
+ const known = memo.get(up);
246
+ if (known !== undefined) {
247
+ return known.hidden;
248
+ }
249
+ }
250
+ return false;
251
+ }
157
252
  function ownVisibilityInfo(el, memo) {
158
253
  let info = memo.get(el);
159
254
  if (info === undefined) {
255
+ if (underKnownHidden(el, memo)) {
256
+ info = {
257
+ hidden: true,
258
+ style: null,
259
+ parent: null,
260
+ clipX: false,
261
+ clipY: false,
262
+ scrollX: false,
263
+ scrollY: false,
264
+ hasClipping: false,
265
+ };
266
+ memo.set(el, info);
267
+ return info;
268
+ }
160
269
  const style = el.ownerDocument.defaultView?.getComputedStyle(el) ?? null;
161
270
  if (selfHidden(el, style)) {
162
271
  info = {
@@ -305,6 +414,9 @@ function collapsed(el, memo) {
305
414
  (own.clipY && 0 === r.height) ||
306
415
  (own.clipX && own.clipY && r.width <= 1 && r.height <= 1)));
307
416
  }
417
+ export function isHiddenSubtree(el, memo) {
418
+ return ownVisibilityInfo(el, visibilityInfoMemo(memo)).hidden;
419
+ }
308
420
  export function isVisible(el, memo) {
309
421
  if (!el.isConnected) {
310
422
  return false;
@@ -314,7 +426,10 @@ export function isVisible(el, memo) {
314
426
  return cached;
315
427
  }
316
428
  const info = visibilityInfoMemo(memo);
317
- const result = visibleBounds(el, info, undefined, true) !== null && !collapsed(el, info);
429
+ const result = !visibilityHidden(ownVisibilityInfo(el, info).style) &&
430
+ visibleBounds(el, info, undefined, true) !== null &&
431
+ !collapsed(el, info) &&
432
+ !backfaceHidden(el, info);
318
433
  if (memo !== undefined) {
319
434
  memo.set(el, result);
320
435
  }
@@ -13,6 +13,7 @@ export declare function digestPixels(data: Uint8ClampedArray): {
13
13
  interface CanvasOptions {
14
14
  sample?: (canvas: HTMLCanvasElement) => CanvasSample | undefined;
15
15
  every?: (run: () => void, ms: number) => () => void;
16
+ onScreen?: (canvas: HTMLCanvasElement) => boolean;
16
17
  }
17
18
  export declare function canvasAfterAct(): void;
18
19
  export declare function installCanvas(emit: Emit, opts?: CanvasOptions): Teardown;
@@ -1,6 +1,7 @@
1
1
  import { CanvasTrigger, EventType } from '@reticlehq/core';
2
2
  export { CanvasTrigger };
3
3
  import { refs } from '../dom/addressing/refs.js';
4
+ import { isInViewport } from '../dom/visibility.js';
4
5
  import { nativeSetInterval } from '../timers/native/native-timers.js';
5
6
  import { observeSafely, observeValue } from './types.js';
6
7
  const SIDE = 16;
@@ -47,6 +48,7 @@ export function canvasAfterAct() {
47
48
  export function installCanvas(emit, opts = {}) {
48
49
  const sample = opts.sample ?? sampleCanvas;
49
50
  const every = opts.every ?? nativeSetInterval;
51
+ const onScreen = opts.onScreen ?? ((canvas) => isInViewport(canvas));
50
52
  const last = new WeakMap();
51
53
  const look = (trigger) => {
52
54
  observeSafely(() => {
@@ -61,7 +63,14 @@ export function installCanvas(emit, opts = {}) {
61
63
  continue;
62
64
  }
63
65
  last.set(canvas, digest);
64
- emit(EventType.CANVAS_DIGEST, { digest, width: read.width, height: read.height, mean, trigger }, refs.refFor(canvas));
66
+ emit(EventType.CANVAS_DIGEST, {
67
+ digest,
68
+ width: read.width,
69
+ height: read.height,
70
+ mean,
71
+ trigger,
72
+ visible: onScreen(canvas),
73
+ }, refs.refFor(canvas));
65
74
  }
66
75
  });
67
76
  };
@@ -1,4 +1,4 @@
1
- import { EventType, TRANSPORT_LIMITS } from '@reticlehq/core';
1
+ import { DROPPED_PRE_ATTACH_FIELD, EventType, PRE_ATTACH_FIELD, RETICLE_PRE_ATTACH_ERRORS, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  import { safeStringify } from '../security/serialization.js';
4
4
  import { requireCapturedMethod } from '../util/captured-method.js';
@@ -15,44 +15,48 @@ const METHOD_EVENT = {
15
15
  function stringifyArgs(args) {
16
16
  return args
17
17
  .map((a) => {
18
- if ('string' === typeof a)
18
+ if ('string' === typeof a) {
19
19
  return a;
20
- if (a instanceof Error)
20
+ }
21
+ if (a instanceof Error) {
21
22
  return a.message;
23
+ }
22
24
  return safeStringify(a);
23
25
  })
24
26
  .join(' ');
25
27
  }
26
28
  const MAX_STACK_LEN = TRANSPORT_LIMITS.MAX_STACK_LENGTH;
27
29
  function capStack(stack) {
28
- if (stack === undefined || 0 === stack.length)
30
+ if (stack === undefined || 0 === stack.length) {
29
31
  return undefined;
32
+ }
30
33
  return stack.length > MAX_STACK_LEN ? stack.slice(0, MAX_STACK_LEN) : stack;
31
34
  }
32
- function firstErrorStack(args) {
33
- for (const arg of args) {
34
- if (arg instanceof Error)
35
- return capStack(arg.stack);
36
- }
37
- return undefined;
35
+ function stackOf(value) {
36
+ const stack = value instanceof Error ? capStack(value.stack) : undefined;
37
+ return stack === undefined ? {} : { stack };
38
38
  }
39
39
  export function installConsole(emit) {
40
40
  const methods = ['log', 'warn', 'error', 'info', 'debug'];
41
41
  const originals = new Map();
42
42
  const patched = new Map();
43
+ let marks = {};
44
+ const report = (method, args) => {
45
+ observeSafely(() => {
46
+ emit(METHOD_EVENT[method], {
47
+ ...marks,
48
+ message: stringifyArgs(args),
49
+ ...('error' === method ? stackOf(args.find((arg) => arg instanceof Error)) : {}),
50
+ });
51
+ });
52
+ };
43
53
  for (const method of methods) {
44
54
  const original = requireCapturedMethod(console, method);
45
55
  originals.set(method, original);
46
56
  const callOriginal = original.bind(console);
47
57
  const wrapper = (...args) => {
48
58
  callOriginal(...args);
49
- observeSafely(() => {
50
- const stack = 'error' === method ? firstErrorStack(args) : undefined;
51
- emit(METHOD_EVENT[method], {
52
- message: stringifyArgs(args),
53
- ...(stack === undefined ? {} : { stack }),
54
- });
55
- });
59
+ report(method, args);
56
60
  };
57
61
  patched.set(method, wrapper);
58
62
  console[method] = wrapper;
@@ -60,9 +64,11 @@ export function installConsole(emit) {
60
64
  const repeats = createRepeatLimiter();
61
65
  const emitError = (payload) => {
62
66
  const decision = repeats.admit(payload);
63
- if (!decision.emit)
67
+ if (!decision.emit) {
64
68
  return;
69
+ }
65
70
  emit(EventType.ERROR_UNCAUGHT, {
71
+ ...marks,
66
72
  ...payload,
67
73
  ...(undefined === decision.repeats ? {} : { repeats: decision.repeats }),
68
74
  });
@@ -70,8 +76,9 @@ export function installConsole(emit) {
70
76
  const onError = (event) => {
71
77
  if (!(event instanceof ErrorEvent)) {
72
78
  const target = event.target;
73
- if (!(target instanceof Element) || target === document)
79
+ if (!(target instanceof Element) || target === document) {
74
80
  return;
81
+ }
75
82
  const tag = target.tagName.toLowerCase();
76
83
  const url = target.getAttribute('src') ?? target.getAttribute('href') ?? '';
77
84
  observeSafely(() => {
@@ -81,37 +88,32 @@ export function installConsole(emit) {
81
88
  return;
82
89
  }
83
90
  observeSafely(() => {
84
- const stack = capStack(event.error instanceof Error ? event.error.stack : undefined);
85
- const payload = {
91
+ emitError({
86
92
  message: event.message,
87
93
  source: event.filename,
88
94
  line: event.lineno,
89
- ...(stack === undefined ? {} : { stack }),
90
- };
91
- emitError(payload);
95
+ ...stackOf(event.error),
96
+ });
92
97
  });
93
98
  };
94
99
  const onRejection = (event) => {
95
100
  observeSafely(() => {
96
101
  const reason = event.reason;
97
- const stack = capStack(reason instanceof Error ? reason.stack : undefined);
98
- const payload = {
102
+ emitError({
99
103
  message: reason instanceof Error ? reason.message : String(reason),
100
104
  kind: 'unhandledrejection',
101
- ...(stack === undefined ? {} : { stack }),
102
- };
103
- emitError(payload);
105
+ ...stackOf(reason),
106
+ });
104
107
  });
105
108
  };
106
109
  const onViolation = (event) => {
107
- const what = 0 === event.blockedURI.length ? 'inline content' : event.blockedURI;
110
+ const uri = event.blockedURI;
111
+ const what = 0 === uri.length ? 'inline content' : uri;
108
112
  const reportOnly = REPORT_ONLY === event.disposition;
109
113
  emit(reportOnly ? EventType.CONSOLE_WARN : EventType.CONSOLE_ERROR, {
110
- message: reportOnly
111
- ? `[Report Only] Content Security Policy directive: ${event.violatedDirective} would block ${what}`
112
- : `Content Security Policy directive: ${event.violatedDirective} blocked ${what}`,
114
+ message: `${reportOnly ? '[Report Only] ' : ''}Content Security Policy directive: ${event.violatedDirective} ${reportOnly ? 'would block' : 'blocked'} ${what}`,
113
115
  kind: CSP_VIOLATION_EVENT,
114
- ...(0 === event.blockedURI.length ? {} : { source: event.blockedURI }),
116
+ ...(0 === uri.length ? {} : { source: uri }),
115
117
  });
116
118
  };
117
119
  const listeners = new AbortController();
@@ -119,10 +121,19 @@ export function installConsole(emit) {
119
121
  window.addEventListener('error', onError, { capture: true, signal });
120
122
  window.addEventListener('unhandledrejection', onRejection, { signal });
121
123
  window.addEventListener(CSP_VIOLATION_EVENT, onViolation, { capture: true, signal });
124
+ observeSafely(() => {
125
+ const mark = { [PRE_ATTACH_FIELD]: true };
126
+ marks = mark;
127
+ globalThis[RETICLE_PRE_ATTACH_ERRORS]?.drain(onError, onRejection, report, (count) => {
128
+ mark[DROPPED_PRE_ATTACH_FIELD] = Number.isInteger(count) && count > 0 ? count : undefined;
129
+ });
130
+ });
131
+ marks = {};
122
132
  return () => {
123
133
  for (const [method, original] of originals) {
124
- if (console[method] === patched.get(method))
134
+ if (console[method] === patched.get(method)) {
125
135
  console[method] = original;
136
+ }
126
137
  }
127
138
  listeners.abort();
128
139
  };
@@ -1,5 +1,5 @@
1
1
  import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
2
- import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
2
+ import { EventType, LivePoliteness, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
3
3
  import { getAccessibleName, getRole, isSensitiveField } from '../dom/a11y.js';
4
4
  import { isVisible } from '../dom/visibility.js';
5
5
  import { refs } from '../dom/addressing/refs.js';
@@ -108,6 +108,24 @@ const named = (el) => {
108
108
  const testid = testidNear(el);
109
109
  return testid === undefined ? {} : { testid };
110
110
  };
111
+ const ALERT_REGION = '[role="alert"]';
112
+ const LIVE_REGION = `[aria-live],${ALERT_REGION},[role="status"]`;
113
+ const liveOf = (el) => {
114
+ const region = el.closest(LIVE_REGION);
115
+ if (null === region) {
116
+ return {};
117
+ }
118
+ const level = region.getAttribute('aria-live');
119
+ if (level === LivePoliteness.ASSERTIVE || level === LivePoliteness.POLITE) {
120
+ return { live: level };
121
+ }
122
+ if (level !== null) {
123
+ return {};
124
+ }
125
+ return {
126
+ live: region.matches(ALERT_REGION) ? LivePoliteness.ASSERTIVE : LivePoliteness.POLITE,
127
+ };
128
+ };
111
129
  export function installDom(emit) {
112
130
  const stopRegistry = installShadowRegistry();
113
131
  const observer = new MutationObserver((records) => {
@@ -149,7 +167,7 @@ export function installDom(emit) {
149
167
  ? REDACTED_VALUE
150
168
  : (record.target.textContent ?? '').trim().slice(0, 80);
151
169
  const old = hide ? undefined : capValue(record.oldValue?.trim() ?? null);
152
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
170
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent), ...liveOf(parent) }, refs.refFor(parent));
153
171
  }
154
172
  continue;
155
173
  }
@@ -164,7 +182,7 @@ export function installDom(emit) {
164
182
  const hide = isSensitiveField(parent);
165
183
  const text = hide ? REDACTED_VALUE : (parent.textContent ?? '').trim().slice(0, 80);
166
184
  const old = hide ? undefined : capValue(textOf(record.removedNodes) ?? null);
167
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
185
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent), ...liveOf(parent) }, refs.refFor(parent));
168
186
  }
169
187
  }
170
188
  }
@@ -226,7 +244,13 @@ export function installDom(emit) {
226
244
  }
227
245
  const { role, name } = gone;
228
246
  removed += 1;
229
- emit(EventType.DOM_REMOVED, { role, name, region: regionKeyOf(record.target) });
247
+ const testid = readTestId(gone.el) ?? readTestId(node);
248
+ emit(EventType.DOM_REMOVED, {
249
+ role,
250
+ name,
251
+ region: regionKeyOf(record.target),
252
+ ...(null === testid ? {} : { testid }),
253
+ });
230
254
  }
231
255
  }
232
256
  if (dropped > 0) {
@@ -0,0 +1,12 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ type EmitNet = (type: EventType, data: Record<string, unknown>) => void;
3
+ export declare function absoluteUrl(raw: string): string;
4
+ export interface ResourceTimingInput {
5
+ emit: EmitNet;
6
+ installedAt: number;
7
+ nextId: () => string;
8
+ reportedNetUrls: Set<string>;
9
+ patchedNetUrls: ReadonlySet<string>;
10
+ }
11
+ export declare function observeResourceTiming(input: ResourceTimingInput): PerformanceObserver | undefined;
12
+ export {};
@@ -0,0 +1,69 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { netUrlFields } from './network-redact.js';
3
+ const RESOURCE_CAP = 200;
4
+ export function absoluteUrl(raw) {
5
+ try {
6
+ return new URL(raw, location.href).href;
7
+ }
8
+ catch {
9
+ return raw;
10
+ }
11
+ }
12
+ function statusFields(entry, withOk) {
13
+ const status = entry.responseStatus ?? 0;
14
+ if (status <= 0) {
15
+ return {};
16
+ }
17
+ return withOk ? { status, ok: status >= 200 && status < 400 } : { status };
18
+ }
19
+ export function observeResourceTiming(input) {
20
+ const { emit, installedAt, nextId, reportedNetUrls, patchedNetUrls } = input;
21
+ if ('undefined' === typeof PerformanceObserver) {
22
+ return undefined;
23
+ }
24
+ let subresourceEvents = 0;
25
+ let unpatchedEvents = 0;
26
+ const onEntry = (entry) => {
27
+ const type = entry.initiatorType || 'other';
28
+ const rawUrl = entry.name;
29
+ const patched = 'fetch' === type || 'xmlhttprequest' === type;
30
+ if (patched && entry.startTime >= installedAt) {
31
+ if (patchedNetUrls.has(absoluteUrl(rawUrl)) || unpatchedEvents >= RESOURCE_CAP) {
32
+ return;
33
+ }
34
+ unpatchedEvents += 1;
35
+ emit(EventType.NET_UNPATCHED, {
36
+ ...netUrlFields(rawUrl),
37
+ initiator: type,
38
+ ...statusFields(entry, false),
39
+ });
40
+ return;
41
+ }
42
+ if (reportedNetUrls.has(rawUrl) || subresourceEvents >= RESOURCE_CAP) {
43
+ return;
44
+ }
45
+ reportedNetUrls.add(rawUrl);
46
+ subresourceEvents += 1;
47
+ emit(EventType.NET_REQUEST, {
48
+ id: nextId(),
49
+ method: 'GET',
50
+ ...netUrlFields(rawUrl),
51
+ durationMs: Math.round(entry.duration),
52
+ initiator: type,
53
+ ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
54
+ ...statusFields(entry, true),
55
+ });
56
+ };
57
+ try {
58
+ const observer = new PerformanceObserver((list) => {
59
+ for (const raw of list.getEntries()) {
60
+ onEntry(raw);
61
+ }
62
+ });
63
+ observer.observe({ type: 'resource', buffered: true });
64
+ return observer;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
@@ -7,6 +7,7 @@ import { redactUrl, netUrlFields } from './net-detail/network-redact.js';
7
7
  import { watchStreamedBody } from './network-stream.js';
8
8
  import { frameFields, isBridgeSocket } from './net-detail/network-frames.js';
9
9
  import { requireCapturedMethod } from '../util/captured-method.js';
10
+ import { absoluteUrl, observeResourceTiming } from './net-detail/resource-timing.js';
10
11
  export { redactUrl, netUrlFields };
11
12
  const ShapeToken = { NULL: '0', STRING: 's', NUMBER: 'n', BOOLEAN: 'b', OTHER: '?' };
12
13
  function shapeOf(value) {
@@ -228,6 +229,7 @@ export function installNetwork(emit, opts = {}) {
228
229
  }
229
230
  const owner = {};
230
231
  pageSlots()[PAGE_CAPTURE] = owner;
232
+ const installedAt = performance.now();
231
233
  const captureBodies = true === opts.captureBodies;
232
234
  const bodyCapture = new AbortController();
233
235
  const captureErrorBodies = false !== opts.captureErrorBodies;
@@ -239,8 +241,11 @@ export function installNetwork(emit, opts = {}) {
239
241
  }
240
242
  let seq = 0;
241
243
  const nextId = () => `n${++seq}`;
244
+ const reportedNetUrls = new Set();
245
+ const patchedNetUrls = new Set();
242
246
  window.fetch = async (input, init) => {
243
247
  const rawUrl = urlOf(input);
248
+ patchedNetUrls.add(absoluteUrl(rawUrl));
244
249
  if (true === opts.ignore?.(rawUrl)) {
245
250
  return callFetch(input, init);
246
251
  }
@@ -353,6 +358,7 @@ export function installNetwork(emit, opts = {}) {
353
358
  rawUrl: String(url),
354
359
  start: 0,
355
360
  });
361
+ patchedNetUrls.add(absoluteUrl(String(url)));
356
362
  });
357
363
  callOpen.call(this, method, url, ...rest);
358
364
  };
@@ -537,50 +543,13 @@ export function installNetwork(emit, opts = {}) {
537
543
  };
538
544
  navProto.sendBeacon = patchedBeacon;
539
545
  }
540
- const reportedNetUrls = new Set();
541
- let subresourceEvents = 0;
542
- const SUBRESOURCE_CAP = 200;
543
- let subresourceObserver;
544
- if (typeof PerformanceObserver !== 'undefined') {
545
- try {
546
- const observer = new PerformanceObserver((list) => {
547
- for (const raw of list.getEntries()) {
548
- const entry = raw;
549
- if (subresourceEvents >= SUBRESOURCE_CAP) {
550
- return;
551
- }
552
- const type = entry.initiatorType || 'other';
553
- if ('fetch' === type || 'xmlhttprequest' === type) {
554
- continue;
555
- }
556
- const rawUrl = entry.name;
557
- if (reportedNetUrls.has(rawUrl)) {
558
- continue;
559
- }
560
- reportedNetUrls.add(rawUrl);
561
- subresourceEvents += 1;
562
- emit(EventType.NET_REQUEST, {
563
- id: nextId(),
564
- method: 'GET',
565
- ...netUrlFields(rawUrl),
566
- durationMs: Math.round(entry.duration),
567
- initiator: type,
568
- ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
569
- ...((entry.responseStatus ?? 0) > 0
570
- ? {
571
- status: entry.responseStatus,
572
- ok: (entry.responseStatus ?? 0) >= 200 && (entry.responseStatus ?? 0) < 400,
573
- }
574
- : {}),
575
- });
576
- }
577
- });
578
- subresourceObserver = observer;
579
- observer.observe({ type: 'resource', buffered: true });
580
- }
581
- catch {
582
- }
583
- }
546
+ const subresourceObserver = observeResourceTiming({
547
+ emit,
548
+ installedAt,
549
+ nextId,
550
+ reportedNetUrls,
551
+ patchedNetUrls,
552
+ });
584
553
  return () => {
585
554
  if (pageSlots()[PAGE_CAPTURE] === owner) {
586
555
  delete pageSlots()[PAGE_CAPTURE];