@reticlehq/browser 3.7.0 → 3.8.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 (92) hide show
  1. package/dist/actions/action-report.d.ts +10 -0
  2. package/dist/actions/action-report.js +29 -2
  3. package/dist/actions/actions-dom.d.ts +4 -0
  4. package/dist/actions/actions-dom.js +20 -4
  5. package/dist/actions/actions.d.ts +1 -0
  6. package/dist/actions/actions.js +35 -17
  7. package/dist/actions/appeared-text.js +2 -2
  8. package/dist/actions/click-target.d.ts +1 -0
  9. package/dist/actions/click-target.js +14 -0
  10. package/dist/actions/danger-context.d.ts +2 -1
  11. package/dist/actions/danger-context.js +20 -5
  12. package/dist/activate.d.ts +14 -0
  13. package/dist/activate.js +43 -0
  14. package/dist/busy.d.ts +3 -0
  15. package/dist/busy.js +42 -0
  16. package/dist/commands/commands.js +45 -17
  17. package/dist/connect-options.d.ts +12 -1
  18. package/dist/connect-options.js +18 -1
  19. package/dist/dom/a11y.d.ts +2 -0
  20. package/dist/dom/a11y.js +191 -159
  21. package/dist/dom/chart.d.ts +3 -2
  22. package/dist/dom/chart.js +3 -2
  23. package/dist/dom/dom-ignore.d.ts +6 -0
  24. package/dist/dom/dom-ignore.js +43 -1
  25. package/dist/dom/layout-audit.d.ts +15 -0
  26. package/dist/dom/layout-audit.js +208 -0
  27. package/dist/dom/paint-context.d.ts +15 -0
  28. package/dist/dom/paint-context.js +78 -0
  29. package/dist/dom/query.d.ts +2 -1
  30. package/dist/dom/query.js +97 -34
  31. package/dist/dom/shadow-registry.d.ts +1 -0
  32. package/dist/dom/shadow-registry.js +27 -0
  33. package/dist/dom/snapshot.d.ts +5 -0
  34. package/dist/dom/snapshot.js +85 -34
  35. package/dist/dom/stable-selector.d.ts +1 -0
  36. package/dist/dom/stable-selector.js +19 -0
  37. package/dist/dom/text-match.d.ts +12 -0
  38. package/dist/dom/text-match.js +115 -0
  39. package/dist/dom/theme.d.ts +1 -0
  40. package/dist/dom/theme.js +21 -9
  41. package/dist/dom/virtualized.js +5 -1
  42. package/dist/index.d.ts +2 -1
  43. package/dist/index.js +5 -0
  44. package/dist/observers/blind-spots.js +3 -2
  45. package/dist/observers/canvas.d.ts +18 -0
  46. package/dist/observers/canvas.js +76 -0
  47. package/dist/observers/dom.js +66 -18
  48. package/dist/observers/field.d.ts +11 -0
  49. package/dist/observers/field.js +117 -13
  50. package/dist/observers/install-all.js +4 -0
  51. package/dist/observers/media.d.ts +2 -0
  52. package/dist/observers/media.js +32 -0
  53. package/dist/observers/net-detail/network-body.d.ts +2 -0
  54. package/dist/observers/net-detail/network-body.js +8 -1
  55. package/dist/observers/network.js +26 -6
  56. package/dist/observers/route.d.ts +2 -0
  57. package/dist/observers/route.js +33 -4
  58. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
  59. package/dist/presenter/chrome/presenter-report-copy.js +0 -14
  60. package/dist/presenter/hud-telemetry.js +1 -1
  61. package/dist/presenter/presenter-chat-views.d.ts +3 -4
  62. package/dist/presenter/presenter-chat-views.js +47 -20
  63. package/dist/presenter/presenter-config.d.ts +1 -0
  64. package/dist/presenter/presenter-config.js +1 -0
  65. package/dist/presenter/presenter-frame.d.ts +3 -2
  66. package/dist/presenter/presenter-frame.js +24 -15
  67. package/dist/presenter/presenter-harness-row.d.ts +4 -15
  68. package/dist/presenter/presenter-harness-row.js +11 -50
  69. package/dist/presenter/presenter-report-styles.js +0 -17
  70. package/dist/presenter/presenter-report.d.ts +1 -2
  71. package/dist/presenter/presenter-report.js +4 -54
  72. package/dist/presenter/presenter-run-state.js +2 -1
  73. package/dist/presenter/presenter-settings.d.ts +2 -3
  74. package/dist/presenter/presenter-settings.js +14 -17
  75. package/dist/presenter/presenter-shell.d.ts +6 -4
  76. package/dist/presenter/presenter-shell.js +37 -7
  77. package/dist/presenter/presenter.js +32 -11
  78. package/dist/registry/store-adapters.d.ts +5 -2
  79. package/dist/registry/store-adapters.js +40 -10
  80. package/dist/reticle-inject.js +602 -611
  81. package/dist/reticle.d.ts +4 -2
  82. package/dist/reticle.js +36 -6
  83. package/dist/security/page-location.d.ts +2 -0
  84. package/dist/security/page-location.js +7 -0
  85. package/dist/security/serialization.js +1 -1
  86. package/dist/storage-keys.d.ts +1 -0
  87. package/dist/storage-keys.js +1 -0
  88. package/dist/timers/native/native-timers.d.ts +1 -0
  89. package/dist/timers/native/native-timers.js +5 -0
  90. package/dist/transport/transport.d.ts +1 -0
  91. package/dist/transport/transport.js +18 -0
  92. package/package.json +6 -2
@@ -0,0 +1,76 @@
1
+ import { CanvasTrigger, EventType } from '@reticlehq/core';
2
+ export { CanvasTrigger };
3
+ import { refs } from '../dom/addressing/refs.js';
4
+ import { nativeSetInterval } from '../timers/native/native-timers.js';
5
+ import { observeSafely, observeValue } from './types.js';
6
+ const SIDE = 16;
7
+ const POLL_MS = 500;
8
+ const MAX_CANVASES = 4;
9
+ const FNV_OFFSET = 0x811c9dc5;
10
+ const FNV_PRIME = 0x01000193;
11
+ const QUANTUM = 8;
12
+ export function digestPixels(data) {
13
+ let hash = FNV_OFFSET;
14
+ const sum = [0, 0, 0];
15
+ const pixels = Math.max(1, data.length / 4);
16
+ for (let i = 0; i < data.length; i += 4) {
17
+ for (let c = 0; c < 3; c += 1) {
18
+ const value = data[i + c] ?? 0;
19
+ sum[c] = (sum[c] ?? 0) + value;
20
+ hash = Math.imul(hash ^ Math.floor(value / QUANTUM), FNV_PRIME) >>> 0;
21
+ }
22
+ }
23
+ const mean = sum.map((s) => Math.round(s / pixels));
24
+ return { digest: hash.toString(16).padStart(8, '0'), mean };
25
+ }
26
+ function sampleCanvas(canvas) {
27
+ if (0 === canvas.width || 0 === canvas.height) {
28
+ return undefined;
29
+ }
30
+ const scratch = document.createElement('canvas');
31
+ scratch.width = SIDE;
32
+ scratch.height = SIDE;
33
+ const ctx = scratch.getContext('2d');
34
+ if (null === ctx) {
35
+ return undefined;
36
+ }
37
+ const data = observeValue(() => {
38
+ ctx.drawImage(canvas, 0, 0, SIDE, SIDE);
39
+ return ctx.getImageData(0, 0, SIDE, SIDE).data;
40
+ });
41
+ return data === undefined ? undefined : { data, width: canvas.width, height: canvas.height };
42
+ }
43
+ let sampleAll;
44
+ export function canvasAfterAct() {
45
+ sampleAll?.(CanvasTrigger.ACT);
46
+ }
47
+ export function installCanvas(emit, opts = {}) {
48
+ const sample = opts.sample ?? sampleCanvas;
49
+ const every = opts.every ?? nativeSetInterval;
50
+ const last = new WeakMap();
51
+ const look = (trigger) => {
52
+ observeSafely(() => {
53
+ const canvases = Array.from(document.querySelectorAll('canvas')).slice(0, MAX_CANVASES);
54
+ for (const canvas of canvases) {
55
+ const read = sample(canvas);
56
+ if (read === undefined) {
57
+ continue;
58
+ }
59
+ const { digest, mean } = digestPixels(read.data);
60
+ if (CanvasTrigger.POLL === trigger && last.get(canvas) === digest) {
61
+ continue;
62
+ }
63
+ last.set(canvas, digest);
64
+ emit(EventType.CANVAS_DIGEST, { digest, width: read.width, height: read.height, mean, trigger }, refs.refFor(canvas));
65
+ }
66
+ });
67
+ };
68
+ sampleAll = look;
69
+ const stop = every(() => look(CanvasTrigger.POLL), POLL_MS);
70
+ return () => {
71
+ stop();
72
+ if (sampleAll === look) {
73
+ sampleAll = undefined;
74
+ }
75
+ };
76
+ }
@@ -4,8 +4,9 @@ import { getAccessibleName, getRole, isSensitiveField, isVisible } from '../dom/
4
4
  import { refs } from '../dom/addressing/refs.js';
5
5
  function regionKeyOf(target) {
6
6
  const el = isElement(target) ? target : null;
7
- if (null === el)
7
+ if (null === el) {
8
8
  return undefined;
9
+ }
9
10
  const labelled = el.closest(testIdSelector());
10
11
  return (labelled ? readTestId(labelled) : null) ?? refs.refFor(el);
11
12
  }
@@ -30,8 +31,9 @@ const WATCHED_ATTRS = [
30
31
  ];
31
32
  const MAX_ATTR_VALUE_LEN = 120;
32
33
  function capValue(value) {
33
- if (null === value)
34
+ if (null === value) {
34
35
  return undefined;
36
+ }
35
37
  return value.length > MAX_ATTR_VALUE_LEN ? `${value.slice(0, MAX_ATTR_VALUE_LEN)}…` : value;
36
38
  }
37
39
  const DIALOG_ROLES = new Set(['dialog', 'alertdialog']);
@@ -44,19 +46,23 @@ const MOUNT_SCAN_LIMIT = 40;
44
46
  function describeMount(node) {
45
47
  const role = getRole(node);
46
48
  const name = getAccessibleName(node);
47
- if (isMeaningful(role, name))
49
+ if (isMeaningful(role, name)) {
48
50
  return { role, name, el: node };
51
+ }
49
52
  let scanned = 0;
50
53
  for (const child of node.querySelectorAll('*')) {
51
- if (scanned >= MOUNT_SCAN_LIMIT)
54
+ if (scanned >= MOUNT_SCAN_LIMIT) {
52
55
  return undefined;
56
+ }
53
57
  scanned += 1;
54
- if (isUnobserved(child))
58
+ if (isUnobserved(child)) {
55
59
  continue;
60
+ }
56
61
  const childRole = getRole(child);
57
62
  const childName = getAccessibleName(child);
58
- if (isMeaningful(childRole, childName))
63
+ if (isMeaningful(childRole, childName)) {
59
64
  return { role: childRole, name: childName, el: child };
65
+ }
60
66
  }
61
67
  return undefined;
62
68
  }
@@ -64,8 +70,9 @@ const TEXT_NODE = 3;
64
70
  function textOf(nodes) {
65
71
  let out = '';
66
72
  for (const node of nodes) {
67
- if (node.nodeType !== TEXT_NODE)
73
+ if (node.nodeType !== TEXT_NODE) {
68
74
  continue;
75
+ }
69
76
  out += node.nodeValue ?? '';
70
77
  }
71
78
  const trimmed = out.trim();
@@ -83,12 +90,30 @@ const SAFE_ON_SECRET = new Set([
83
90
  function secretAttr(el, attr) {
84
91
  return !SAFE_ON_SECRET.has(attr) && !attr.startsWith('aria-') && isSensitiveField(el);
85
92
  }
93
+ const TESTID_DEPTH = 3;
94
+ const MAX_INITIAL_TEXTS = 50;
95
+ function testidNear(el) {
96
+ let at = el;
97
+ for (let depth = 0; at !== null && depth <= TESTID_DEPTH; depth += 1) {
98
+ const id = readTestId(at);
99
+ if (id !== null && id.length > 0) {
100
+ return id;
101
+ }
102
+ at = at.parentElement;
103
+ }
104
+ return undefined;
105
+ }
106
+ const named = (el) => {
107
+ const testid = testidNear(el);
108
+ return testid === undefined ? {} : { testid };
109
+ };
86
110
  export function installDom(emit) {
87
111
  const stopRegistry = installShadowRegistry();
88
112
  const observer = new MutationObserver((records) => {
89
113
  let added = 0;
90
114
  let removed = 0;
91
115
  let changed = 0;
116
+ let initials = 0;
92
117
  let dropped = 0;
93
118
  for (const record of records) {
94
119
  if ('attributes' === record.type) {
@@ -123,36 +148,54 @@ export function installDom(emit) {
123
148
  ? REDACTED_VALUE
124
149
  : (record.target.textContent ?? '').trim().slice(0, 80);
125
150
  const old = hide ? undefined : capValue(record.oldValue?.trim() ?? null);
126
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
151
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
127
152
  }
128
153
  continue;
129
154
  }
130
155
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
131
156
  const parent = isElement(record.target) ? record.target : null;
132
157
  if (parent !== null && !isUnobserved(parent)) {
133
- if (changed >= MAX_PER_BATCH)
158
+ if (changed >= MAX_PER_BATCH) {
134
159
  dropped += 1;
160
+ }
135
161
  else {
136
162
  changed += 1;
137
163
  const hide = isSensitiveField(parent);
138
164
  const text = hide ? REDACTED_VALUE : (parent.textContent ?? '').trim().slice(0, 80);
139
165
  const old = hide ? undefined : capValue(textOf(record.removedNodes) ?? null);
140
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
166
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
141
167
  }
142
168
  }
143
169
  }
144
170
  for (const node of record.addedNodes) {
145
- if (!isElement(node))
171
+ if (!isElement(node)) {
146
172
  continue;
173
+ }
147
174
  if (added >= MAX_PER_BATCH) {
148
175
  dropped += 1;
149
176
  continue;
150
177
  }
151
- if (isUnobserved(node))
178
+ if (isUnobserved(node)) {
152
179
  continue;
180
+ }
181
+ const testids = [node, ...Array.from(node.querySelectorAll(testIdSelector()))].filter((el) => readTestId(el) !== null);
182
+ for (const el of testids) {
183
+ const text = (el.textContent ?? '').trim();
184
+ if (0 === text.length) {
185
+ continue;
186
+ }
187
+ if (initials >= MAX_INITIAL_TEXTS) {
188
+ dropped += 1;
189
+ continue;
190
+ }
191
+ initials += 1;
192
+ const shown = isSensitiveField(el) ? REDACTED_VALUE : text.slice(0, 80);
193
+ emit(EventType.DOM_TEXT, { text: shown, testid: readTestId(el) ?? '', initial: true }, refs.refFor(el));
194
+ }
153
195
  const mounted = describeMount(node);
154
- if (mounted === undefined)
196
+ if (mounted === undefined) {
155
197
  continue;
198
+ }
156
199
  const { role, name } = mounted;
157
200
  added += 1;
158
201
  const ref = refs.refFor(mounted.el);
@@ -160,13 +203,15 @@ export function installDom(emit) {
160
203
  if (DIALOG_ROLES.has(role) ||
161
204
  LIVE_ROLES.has(role) ||
162
205
  'true' === node.getAttribute('aria-modal')) {
163
- if (isVisible(node))
206
+ if (isVisible(node)) {
164
207
  emit(EventType.VISIBLE_SHOWN, { role, name }, ref);
208
+ }
165
209
  }
166
210
  }
167
211
  for (const node of record.removedNodes) {
168
- if (!isElement(node))
212
+ if (!isElement(node)) {
169
213
  continue;
214
+ }
170
215
  if (removed >= MAX_PER_BATCH) {
171
216
  dropped += 1;
172
217
  continue;
@@ -175,15 +220,17 @@ export function installDom(emit) {
175
220
  continue;
176
221
  }
177
222
  const gone = describeMount(node);
178
- if (gone === undefined)
223
+ if (gone === undefined) {
179
224
  continue;
225
+ }
180
226
  const { role, name } = gone;
181
227
  removed += 1;
182
228
  emit(EventType.DOM_REMOVED, { role, name, region: regionKeyOf(record.target) });
183
229
  }
184
230
  }
185
- if (dropped > 0)
231
+ if (dropped > 0) {
186
232
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
233
+ }
187
234
  });
188
235
  const options = {
189
236
  subtree: true,
@@ -195,8 +242,9 @@ export function installDom(emit) {
195
242
  characterDataOldValue: true,
196
243
  };
197
244
  observer.observe(document.documentElement, options);
198
- for (const root of capturedRoots())
245
+ for (const root of capturedRoots()) {
199
246
  observer.observe(root, options);
247
+ }
200
248
  const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
201
249
  const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
202
250
  return () => {
@@ -1,2 +1,13 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
+ export interface FieldReading {
3
+ value: string;
4
+ checked?: boolean;
5
+ }
6
+ export interface ActedField {
7
+ before: FieldReading;
8
+ stop: () => void;
9
+ written: () => boolean;
10
+ }
11
+ export declare function watchActedField(el: Element): ActedField | undefined;
12
+ export declare function publishSettledField(el: Element, acted: ActedField | undefined): void;
2
13
  export declare function installField(emit: Emit): Teardown;
@@ -1,13 +1,34 @@
1
1
  import { readTestId } from '../dom/addressing/testid-attr.js';
2
- import { EventType } from '@reticlehq/core';
2
+ import { EventType, FieldChangeField, FieldChangeKind } from '@reticlehq/core';
3
3
  import { getAccessibleName, isSensitiveField } from '../dom/a11y.js';
4
4
  const MAX_VALUE_LEN = 160;
5
5
  const BURST_MS = 150;
6
+ const CHECKBOX_TYPE = 'checkbox';
7
+ const RADIO_TYPE = 'radio';
8
+ let settledEmit;
6
9
  function isField(target) {
7
10
  return (target instanceof HTMLInputElement ||
8
11
  target instanceof HTMLTextAreaElement ||
9
12
  target instanceof HTMLSelectElement);
10
13
  }
14
+ function isCheckable(element) {
15
+ return (element instanceof HTMLInputElement &&
16
+ (CHECKBOX_TYPE === element.type || RADIO_TYPE === element.type));
17
+ }
18
+ function readingOf(element) {
19
+ if (isCheckable(element))
20
+ return { value: element.value, checked: element.checked };
21
+ return { value: element.value };
22
+ }
23
+ function readingMoved(before, after) {
24
+ if (before.checked !== undefined || after.checked !== undefined) {
25
+ return before.checked !== after.checked;
26
+ }
27
+ return before.value !== after.value;
28
+ }
29
+ function clip(value) {
30
+ return value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value;
31
+ }
11
32
  function fieldName(element) {
12
33
  const testid = readTestId(element);
13
34
  if (null !== testid && testid.length > 0)
@@ -20,21 +41,102 @@ function fieldName(element) {
20
41
  const id = element.getAttribute('id');
21
42
  return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
22
43
  }
44
+ function fieldPayload(element, kind, after, before) {
45
+ const secret = isSensitiveField(element);
46
+ const payload = {
47
+ [FieldChangeField.FIELD]: fieldName(element),
48
+ [FieldChangeField.KIND]: kind,
49
+ [FieldChangeField.LENGTH]: after.value.length,
50
+ };
51
+ if (after.checked !== undefined)
52
+ payload[FieldChangeField.CHECKED] = after.checked;
53
+ if (before?.checked !== undefined)
54
+ payload[FieldChangeField.PREVIOUS_CHECKED] = before.checked;
55
+ if (secret) {
56
+ payload[FieldChangeField.REDACTED] = true;
57
+ return payload;
58
+ }
59
+ payload[FieldChangeField.VALUE] = clip(after.value);
60
+ if (before !== undefined && before.checked === undefined) {
61
+ payload[FieldChangeField.PREVIOUS] = clip(before.value);
62
+ }
63
+ return payload;
64
+ }
65
+ function settledProperty(el) {
66
+ return isCheckable(el) ? 'checked' : 'value';
67
+ }
68
+ function inheritedDescriptor(el, prop) {
69
+ for (let proto = Object.getPrototypeOf(el); proto !== null;) {
70
+ const found = Object.getOwnPropertyDescriptor(proto, prop);
71
+ if (found !== undefined)
72
+ return found;
73
+ proto = Object.getPrototypeOf(proto);
74
+ }
75
+ return undefined;
76
+ }
77
+ function watchWrites(el, prop) {
78
+ const own = Object.getOwnPropertyDescriptor(el, prop);
79
+ const target = own ?? inheritedDescriptor(el, prop);
80
+ const get = target === undefined ? undefined : Reflect.get(target, 'get');
81
+ const set = target === undefined ? undefined : Reflect.get(target, 'set');
82
+ let written = false;
83
+ const usable = 'function' === typeof get && 'function' === typeof set;
84
+ if (!usable || false === own?.configurable) {
85
+ return { stop: () => { }, written: () => written };
86
+ }
87
+ Object.defineProperty(el, prop, {
88
+ configurable: true,
89
+ enumerable: target?.enumerable ?? false,
90
+ get() {
91
+ return Reflect.apply(get, this, []);
92
+ },
93
+ set(next) {
94
+ written = true;
95
+ Reflect.apply(set, this, [next]);
96
+ },
97
+ });
98
+ let stopped = false;
99
+ return {
100
+ stop: () => {
101
+ if (stopped)
102
+ return;
103
+ stopped = true;
104
+ if (own === undefined)
105
+ Reflect.deleteProperty(el, prop);
106
+ else
107
+ Object.defineProperty(el, prop, own);
108
+ },
109
+ written: () => written,
110
+ };
111
+ }
112
+ export function watchActedField(el) {
113
+ if (!isField(el) || el instanceof HTMLSelectElement)
114
+ return undefined;
115
+ return { before: readingOf(el), ...watchWrites(el, settledProperty(el)) };
116
+ }
117
+ export function publishSettledField(el, acted) {
118
+ const emit = settledEmit;
119
+ if (acted === undefined)
120
+ return;
121
+ acted.stop();
122
+ if (emit === undefined || !isField(el) || !el.isConnected || !acted.written())
123
+ return;
124
+ const before = acted.before;
125
+ if (isSensitiveField(el) && before.checked === undefined)
126
+ return;
127
+ const after = readingOf(el);
128
+ if (!readingMoved(before, after))
129
+ return;
130
+ emit(EventType.FIELD_CHANGE, fieldPayload(el, FieldChangeKind.SETTLED, after, before));
131
+ }
23
132
  export function installField(emit) {
24
133
  const ac = new AbortController();
25
134
  const { signal } = ac;
135
+ const previousEmit = settledEmit;
136
+ settledEmit = emit;
26
137
  const pending = new Map();
27
138
  const report = (element, kind) => {
28
- const value = element.value;
29
- const secret = isSensitiveField(element);
30
- emit(EventType.FIELD_CHANGE, {
31
- field: fieldName(element),
32
- kind,
33
- length: value.length,
34
- ...(secret
35
- ? { redacted: true }
36
- : { value: value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value }),
37
- });
139
+ emit(EventType.FIELD_CHANGE, fieldPayload(element, kind, readingOf(element)));
38
140
  };
39
141
  const clear = (element) => {
40
142
  const timer = pending.get(element);
@@ -49,7 +151,7 @@ export function installField(emit) {
49
151
  clear(element);
50
152
  pending.set(element, setTimeout(() => {
51
153
  pending.delete(element);
52
- report(element, 'input');
154
+ report(element, FieldChangeKind.INPUT);
53
155
  }, BURST_MS));
54
156
  };
55
157
  const onChange = (event) => {
@@ -57,7 +159,7 @@ export function installField(emit) {
57
159
  if (!isField(element))
58
160
  return;
59
161
  clear(element);
60
- report(element, 'change');
162
+ report(element, FieldChangeKind.CHANGE);
61
163
  };
62
164
  document.addEventListener('input', onInput, { signal, capture: true });
63
165
  document.addEventListener('change', onChange, { signal, capture: true });
@@ -65,6 +167,8 @@ export function installField(emit) {
65
167
  for (const timer of pending.values())
66
168
  clearTimeout(timer);
67
169
  pending.clear();
170
+ if (settledEmit === emit)
171
+ settledEmit = previousEmit;
68
172
  ac.abort();
69
173
  };
70
174
  }
@@ -17,6 +17,8 @@ import { installDialogs } from './dialogs.js';
17
17
  import { installAnimation } from './animation.js';
18
18
  import { installScroll } from './scroll.js';
19
19
  import { installHealth } from './health.js';
20
+ import { installMedia } from './media.js';
21
+ import { installCanvas } from './canvas.js';
20
22
  import { reportSdkFailure, SdkSite } from './sdk-failure.js';
21
23
  function guard(emit, site, install) {
22
24
  try {
@@ -65,5 +67,7 @@ export function installAllObservers(emit, options) {
65
67
  guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
66
68
  guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
67
69
  guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)),
70
+ guard(emit, SdkSite.DOM_OBSERVER, () => installMedia(emit)),
71
+ guard(emit, SdkSite.DOM_OBSERVER, () => installCanvas(emit)),
68
72
  ];
69
73
  }
@@ -0,0 +1,2 @@
1
+ import { type Emit, type Teardown } from './types.js';
2
+ export declare function installMedia(emit: Emit): Teardown;
@@ -0,0 +1,32 @@
1
+ import { EventType, MediaEvent } from '@reticlehq/core';
2
+ import { refs } from '../dom/addressing/refs.js';
3
+ import { observeSafely } from './types.js';
4
+ const TAGS = { VIDEO: 'video', AUDIO: 'audio' };
5
+ export function installMedia(emit) {
6
+ const ac = new AbortController();
7
+ const onMedia = (event) => {
8
+ observeSafely(() => {
9
+ const el = event.target;
10
+ if (!(el instanceof HTMLMediaElement)) {
11
+ return;
12
+ }
13
+ const tag = TAGS[el.tagName];
14
+ if (tag === undefined) {
15
+ return;
16
+ }
17
+ const duration = Number.isFinite(el.duration) ? el.duration : undefined;
18
+ emit(EventType.MEDIA_STATE, {
19
+ event: event.type,
20
+ tag,
21
+ currentTime: el.currentTime,
22
+ ...(duration === undefined ? {} : { duration }),
23
+ readyState: el.readyState,
24
+ paused: el.paused,
25
+ }, refs.refFor(el));
26
+ });
27
+ };
28
+ for (const type of Object.values(MediaEvent)) {
29
+ document.addEventListener(type, onMedia, { capture: true, signal: ac.signal });
30
+ }
31
+ return () => ac.abort();
32
+ }
@@ -1,5 +1,7 @@
1
+ import { ResponseBodyReason } from '@reticlehq/core';
1
2
  export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
2
3
  export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
4
+ export declare function skippedBodyReason(contentType: string | null): ResponseBodyReason | undefined;
3
5
  export declare function isCapturableType(contentType: string | null): boolean;
4
6
  export declare function setNetworkBodyMaxChars(chars: number): void;
5
7
  export declare function networkBodyMaxChars(): number;
@@ -1,4 +1,4 @@
1
- import { REDACTED_VALUE } from '@reticlehq/core';
1
+ import { REDACTED_VALUE, ResponseBodyReason } from '@reticlehq/core';
2
2
  import { isSensitiveKey, safeStringify, scrubKnownSecrets } from '../../security/serialization.js';
3
3
  import { nativeClearTimeout, nativeSetTimeout } from '../../timers/native/native-timers.js';
4
4
  const CAPTURABLE_CONTENT = /application\/json|text\/|application\/xml|x-www-form-urlencoded|graphql/i;
@@ -25,6 +25,13 @@ export async function withBodyDeadline(read) {
25
25
  nativeClearTimeout(timer);
26
26
  }
27
27
  }
28
+ export function skippedBodyReason(contentType) {
29
+ if (null === contentType)
30
+ return undefined;
31
+ return STREAMING_CONTENT.test(contentType)
32
+ ? ResponseBodyReason.STREAMING
33
+ : ResponseBodyReason.NOT_TEXT;
34
+ }
28
35
  export function isCapturableType(contentType) {
29
36
  if (null === contentType)
30
37
  return false;
@@ -1,7 +1,7 @@
1
- import { BlindSpotKind, EventType, NEXT_ACTION_FIELD, NEXT_ACTION_HEADER, REQUEST_SHAPE_FIELD, REQUEST_SHAPE_NONE, RETICLE_WS_PATH, StreamTransport, StreamDirection, fnv1a, } from '@reticlehq/core';
1
+ import { BlindSpotKind, EventType, NEXT_ACTION_FIELD, NEXT_ACTION_HEADER, REQUEST_SHAPE_FIELD, REQUEST_SHAPE_NONE, RETICLE_WS_PATH, ResponseBodyReason, StreamTransport, StreamDirection, fnv1a, } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
3
  import { observeSafely, observeValue } from './types.js';
4
- import { isCapturableType, projectBody, projectErrorBody, shouldCaptureErrorBody, withBodyDeadline, } from './net-detail/network-body.js';
4
+ import { isCapturableType, projectBody, projectErrorBody, shouldCaptureErrorBody, skippedBodyReason, withBodyDeadline, } from './net-detail/network-body.js';
5
5
  import { redactUrl, netUrlFields } from './net-detail/network-redact.js';
6
6
  import { watchStreamedBody } from './network-stream.js';
7
7
  import { requireCapturedMethod } from '../util/captured-method.js';
@@ -230,7 +230,13 @@ function isNativeFetch(fn) {
230
230
  }
231
231
  }
232
232
  const OURS = new WeakSet();
233
+ const PAGE_CAPTURE = Symbol.for('reticle.network');
234
+ const pageSlots = () => window;
233
235
  export function installNetwork(emit, opts = {}) {
236
+ if (undefined !== pageSlots()[PAGE_CAPTURE])
237
+ return () => undefined;
238
+ const owner = {};
239
+ pageSlots()[PAGE_CAPTURE] = owner;
234
240
  const captureBodies = true === opts.captureBodies;
235
241
  const captureErrorBodies = false !== opts.captureErrorBodies;
236
242
  const reinterpret = opts.reinterpret;
@@ -301,7 +307,9 @@ export function installNetwork(emit, opts = {}) {
301
307
  catch {
302
308
  }
303
309
  void (async () => {
304
- let responseBodyFields = {};
310
+ let responseBodyFields = captureBodies
311
+ ? { responseBodyReason: ResponseBodyReason.UNREADABLE }
312
+ : {};
305
313
  if (clone !== undefined) {
306
314
  try {
307
315
  const text = await withBodyDeadline(clone.text());
@@ -312,7 +320,7 @@ export function installNetwork(emit, opts = {}) {
312
320
  responseBodyFields = {
313
321
  responseBody: body,
314
322
  ...(truncated ? { responseBodyTruncated: true } : {}),
315
- ...(captureBodies ? {} : { responseBodyReason: 'error-status' }),
323
+ ...(captureBodies ? {} : { responseBodyReason: ResponseBodyReason.ERROR_STATUS }),
316
324
  };
317
325
  }
318
326
  }
@@ -327,7 +335,8 @@ export function installNetwork(emit, opts = {}) {
327
335
  })().catch(() => undefined);
328
336
  }
329
337
  else {
330
- emitRequest({});
338
+ const skipped = captureBodies ? skippedBodyReason(contentType) : undefined;
339
+ emitRequest(skipped === undefined ? {} : { responseBodyReason: skipped });
331
340
  }
332
341
  return res;
333
342
  }
@@ -397,6 +406,13 @@ export function installNetwork(emit, opts = {}) {
397
406
  let responseBodyFields = {};
398
407
  const textReadable = '' === this.responseType || 'text' === this.responseType;
399
408
  const wantXhrErrorBody = shouldCaptureErrorBody(this.status, captureErrorBodies);
409
+ if (captureBodies) {
410
+ const skipped = isCapturableType(xhrContentType)
411
+ ? ResponseBodyReason.UNREADABLE
412
+ : skippedBodyReason(xhrContentType);
413
+ if (skipped !== undefined)
414
+ responseBodyFields = { responseBodyReason: skipped };
415
+ }
400
416
  if ((captureBodies || wantXhrErrorBody) &&
401
417
  textReadable &&
402
418
  isCapturableType(xhrContentType)) {
@@ -407,7 +423,9 @@ export function installNetwork(emit, opts = {}) {
407
423
  responseBodyFields = {
408
424
  responseBody: rb,
409
425
  ...(truncated ? { responseBodyTruncated: true } : {}),
410
- ...(captureBodies ? {} : { responseBodyReason: 'error-status' }),
426
+ ...(captureBodies
427
+ ? {}
428
+ : { responseBodyReason: ResponseBodyReason.ERROR_STATUS }),
411
429
  };
412
430
  }
413
431
  catch {
@@ -579,6 +597,8 @@ export function installNetwork(emit, opts = {}) {
579
597
  }
580
598
  }
581
599
  return () => {
600
+ if (pageSlots()[PAGE_CAPTURE] === owner)
601
+ delete pageSlots()[PAGE_CAPTURE];
582
602
  subresourceObserver?.disconnect();
583
603
  if (window.fetch === patchedFetch)
584
604
  window.fetch = origFetch;
@@ -1,2 +1,4 @@
1
1
  import { type Emit, type Teardown } from './types.js';
2
+ export declare function declareRoutes(patterns: readonly string[]): void;
3
+ export declare function patternOf(pathname: string): string | undefined;
2
4
  export declare function installRoute(emit: Emit): Teardown;