@reticlehq/browser 3.6.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 (129) hide show
  1. package/dist/actions/action-report.d.ts +64 -0
  2. package/dist/actions/action-report.js +87 -0
  3. package/dist/actions/actions-dom.d.ts +5 -0
  4. package/dist/actions/actions-dom.js +32 -4
  5. package/dist/actions/actions-press.d.ts +1 -0
  6. package/dist/actions/actions-press.js +25 -1
  7. package/dist/actions/actions.d.ts +2 -37
  8. package/dist/actions/actions.js +56 -80
  9. package/dist/actions/appeared-text.js +5 -2
  10. package/dist/actions/click-target.d.ts +1 -0
  11. package/dist/actions/click-target.js +14 -0
  12. package/dist/actions/danger-context.d.ts +4 -1
  13. package/dist/actions/danger-context.js +45 -4
  14. package/dist/actions/value-input.d.ts +1 -0
  15. package/dist/actions/value-input.js +11 -0
  16. package/dist/activate.d.ts +14 -0
  17. package/dist/activate.js +43 -0
  18. package/dist/busy.d.ts +3 -0
  19. package/dist/busy.js +42 -0
  20. package/dist/commands/commands.js +49 -17
  21. package/dist/connect-options.d.ts +12 -1
  22. package/dist/connect-options.js +18 -1
  23. package/dist/dom/a11y.d.ts +2 -0
  24. package/dist/dom/a11y.js +353 -80
  25. package/dist/dom/addressing/coverage-markers.d.ts +6 -0
  26. package/dist/dom/addressing/coverage-markers.js +13 -0
  27. package/dist/dom/chart.d.ts +3 -2
  28. package/dist/dom/chart.js +3 -2
  29. package/dist/dom/dom-ignore.d.ts +8 -0
  30. package/dist/dom/dom-ignore.js +57 -2
  31. package/dist/dom/layout-audit.d.ts +15 -0
  32. package/dist/dom/layout-audit.js +208 -0
  33. package/dist/dom/paint-context.d.ts +15 -0
  34. package/dist/dom/paint-context.js +78 -0
  35. package/dist/dom/query.d.ts +2 -1
  36. package/dist/dom/query.js +163 -38
  37. package/dist/dom/render-page.d.ts +12 -0
  38. package/dist/dom/render-page.js +135 -0
  39. package/dist/dom/shadow-registry.d.ts +1 -0
  40. package/dist/dom/shadow-registry.js +27 -0
  41. package/dist/dom/snapshot.d.ts +5 -0
  42. package/dist/dom/snapshot.js +135 -36
  43. package/dist/dom/stable-selector.d.ts +1 -0
  44. package/dist/dom/stable-selector.js +19 -0
  45. package/dist/dom/text-match.d.ts +12 -0
  46. package/dist/dom/text-match.js +115 -0
  47. package/dist/dom/theme.d.ts +1 -0
  48. package/dist/dom/theme.js +21 -9
  49. package/dist/dom/virtualized.js +5 -1
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.js +5 -0
  52. package/dist/observers/blind-spots.js +3 -2
  53. package/dist/observers/canvas.d.ts +18 -0
  54. package/dist/observers/canvas.js +76 -0
  55. package/dist/observers/dom.js +96 -32
  56. package/dist/observers/field.d.ts +11 -0
  57. package/dist/observers/field.js +116 -30
  58. package/dist/observers/install-all.js +4 -0
  59. package/dist/observers/media.d.ts +2 -0
  60. package/dist/observers/media.js +32 -0
  61. package/dist/observers/net-detail/network-body.d.ts +2 -0
  62. package/dist/observers/net-detail/network-body.js +8 -1
  63. package/dist/observers/network.js +82 -13
  64. package/dist/observers/perf.js +53 -1
  65. package/dist/observers/route.d.ts +2 -0
  66. package/dist/observers/route.js +39 -9
  67. package/dist/presenter/carousel/carousel.js +2 -7
  68. package/dist/presenter/carousel/panel-slides.d.ts +2 -4
  69. package/dist/presenter/carousel/panel-slides.js +17 -15
  70. package/dist/presenter/chrome/log-memory.d.ts +1 -0
  71. package/dist/presenter/chrome/presenter-log.d.ts +7 -0
  72. package/dist/presenter/chrome/presenter-log.js +48 -8
  73. package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
  74. package/dist/presenter/chrome/presenter-morph.js +34 -0
  75. package/dist/presenter/chrome/presenter-report-copy.d.ts +12 -4
  76. package/dist/presenter/chrome/presenter-report-copy.js +12 -4
  77. package/dist/presenter/hud-telemetry.js +55 -4
  78. package/dist/presenter/presenter-account-styles.js +3 -3
  79. package/dist/presenter/presenter-account.d.ts +27 -5
  80. package/dist/presenter/presenter-account.js +83 -6
  81. package/dist/presenter/presenter-agent-link.d.ts +9 -0
  82. package/dist/presenter/presenter-agent-link.js +62 -0
  83. package/dist/presenter/presenter-chat-views.d.ts +25 -4
  84. package/dist/presenter/presenter-chat-views.js +339 -72
  85. package/dist/presenter/presenter-config.d.ts +3 -0
  86. package/dist/presenter/presenter-config.js +2 -0
  87. package/dist/presenter/presenter-controls.d.ts +0 -1
  88. package/dist/presenter/presenter-controls.js +13 -91
  89. package/dist/presenter/presenter-design-tokens.js +1 -1
  90. package/dist/presenter/presenter-frame.d.ts +3 -2
  91. package/dist/presenter/presenter-frame.js +31 -24
  92. package/dist/presenter/presenter-harness-row.d.ts +56 -0
  93. package/dist/presenter/presenter-harness-row.js +170 -0
  94. package/dist/presenter/presenter-personas.d.ts +35 -0
  95. package/dist/presenter/presenter-personas.js +95 -0
  96. package/dist/presenter/presenter-plan.js +25 -24
  97. package/dist/presenter/presenter-report-styles.js +23 -22
  98. package/dist/presenter/presenter-report.js +29 -13
  99. package/dist/presenter/presenter-run-state.js +2 -1
  100. package/dist/presenter/presenter-settings-styles.js +8 -4
  101. package/dist/presenter/presenter-settings.d.ts +4 -4
  102. package/dist/presenter/presenter-settings.js +66 -28
  103. package/dist/presenter/presenter-shell-styles.js +12 -3
  104. package/dist/presenter/presenter-shell.d.ts +11 -3
  105. package/dist/presenter/presenter-shell.js +60 -22
  106. package/dist/presenter/presenter-styles.js +0 -2
  107. package/dist/presenter/presenter-workspace.js +10 -3
  108. package/dist/presenter/presenter.d.ts +3 -2
  109. package/dist/presenter/presenter.js +107 -25
  110. package/dist/registry/declared-platform.js +2 -0
  111. package/dist/registry/store-adapters.d.ts +5 -2
  112. package/dist/registry/store-adapters.js +40 -10
  113. package/dist/reticle-inject.js +771 -703
  114. package/dist/reticle.d.ts +4 -2
  115. package/dist/reticle.js +72 -8
  116. package/dist/security/page-location.d.ts +2 -0
  117. package/dist/security/page-location.js +7 -0
  118. package/dist/security/serialization.js +1 -1
  119. package/dist/storage-keys.d.ts +4 -2
  120. package/dist/storage-keys.js +8 -3
  121. package/dist/timers/native/native-timers.d.ts +1 -0
  122. package/dist/timers/native/native-timers.js +5 -0
  123. package/dist/transport/transport.d.ts +1 -0
  124. package/dist/transport/transport.js +23 -3
  125. package/dist/transport/unreachable-message.d.ts +1 -1
  126. package/dist/transport/unreachable-message.js +3 -5
  127. package/package.json +6 -2
  128. package/dist/presenter/carousel/offer-card.d.ts +0 -21
  129. package/dist/presenter/carousel/offer-card.js +0 -56
@@ -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
+ }
@@ -1,15 +1,16 @@
1
1
  import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
2
- import { EventType, TruncationChannel } from '@reticlehq/core';
3
- import { getAccessibleName, getRole, isVisible } from '../dom/a11y.js';
2
+ import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
3
+ import { getAccessibleName, getRole, isSensitiveField, isVisible } from '../dom/a11y.js';
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
  }
12
- import { isReticleOverlay } from '../dom/dom-ignore.js';
13
+ import { isUnobserved } from '../dom/dom-ignore.js';
13
14
  import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
14
15
  import { isElement } from '../dom/realm.js';
15
16
  import { observeSameOriginFrames } from './frames.js';
@@ -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 (isReticleOverlay(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,31 +70,63 @@ 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();
72
79
  return trimmed.length > 0 ? trimmed : undefined;
73
80
  }
81
+ const SAFE_ON_SECRET = new Set([
82
+ 'class',
83
+ 'style',
84
+ 'type',
85
+ 'disabled',
86
+ 'readonly',
87
+ 'required',
88
+ 'hidden',
89
+ ]);
90
+ function secretAttr(el, attr) {
91
+ return !SAFE_ON_SECRET.has(attr) && !attr.startsWith('aria-') && isSensitiveField(el);
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
+ };
74
110
  export function installDom(emit) {
75
111
  const stopRegistry = installShadowRegistry();
76
112
  const observer = new MutationObserver((records) => {
77
113
  let added = 0;
78
114
  let removed = 0;
79
115
  let changed = 0;
116
+ let initials = 0;
80
117
  let dropped = 0;
81
118
  for (const record of records) {
82
119
  if ('attributes' === record.type) {
83
120
  const target = record.target;
84
- if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
121
+ if (isElement(target) && record.attributeName !== null && !isUnobserved(target)) {
85
122
  if (changed >= MAX_PER_BATCH) {
86
123
  dropped += 1;
87
124
  continue;
88
125
  }
89
126
  changed += 1;
90
- const value = capValue(target.getAttribute(record.attributeName));
91
- const old = capValue(record.oldValue);
127
+ const hide = secretAttr(target, record.attributeName);
128
+ const value = hide ? REDACTED_VALUE : capValue(target.getAttribute(record.attributeName));
129
+ const old = hide && null !== record.oldValue ? REDACTED_VALUE : capValue(record.oldValue);
92
130
  emit(EventType.DOM_ATTR, {
93
131
  attr: record.attributeName,
94
132
  ...(value === undefined ? {} : { value }),
@@ -99,43 +137,65 @@ export function installDom(emit) {
99
137
  }
100
138
  if ('characterData' === record.type) {
101
139
  const parent = record.target.parentElement;
102
- if (parent !== null && !isReticleOverlay(parent)) {
140
+ if (parent !== null && !isUnobserved(parent)) {
103
141
  if (changed >= MAX_PER_BATCH) {
104
142
  dropped += 1;
105
143
  continue;
106
144
  }
107
145
  changed += 1;
108
- const text = (record.target.textContent ?? '').trim().slice(0, 80);
109
- const old = capValue(record.oldValue?.trim() ?? null);
110
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
146
+ const hide = isSensitiveField(parent);
147
+ const text = hide
148
+ ? REDACTED_VALUE
149
+ : (record.target.textContent ?? '').trim().slice(0, 80);
150
+ const old = hide ? undefined : capValue(record.oldValue?.trim() ?? null);
151
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
111
152
  }
112
153
  continue;
113
154
  }
114
155
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
115
156
  const parent = isElement(record.target) ? record.target : null;
116
- if (parent !== null && !isReticleOverlay(parent)) {
117
- if (changed >= MAX_PER_BATCH)
157
+ if (parent !== null && !isUnobserved(parent)) {
158
+ if (changed >= MAX_PER_BATCH) {
118
159
  dropped += 1;
160
+ }
119
161
  else {
120
162
  changed += 1;
121
- const text = (parent.textContent ?? '').trim().slice(0, 80);
122
- const old = capValue(textOf(record.removedNodes) ?? null);
123
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
163
+ const hide = isSensitiveField(parent);
164
+ const text = hide ? REDACTED_VALUE : (parent.textContent ?? '').trim().slice(0, 80);
165
+ const old = hide ? undefined : capValue(textOf(record.removedNodes) ?? null);
166
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
124
167
  }
125
168
  }
126
169
  }
127
170
  for (const node of record.addedNodes) {
128
- if (!isElement(node))
171
+ if (!isElement(node)) {
129
172
  continue;
173
+ }
130
174
  if (added >= MAX_PER_BATCH) {
131
175
  dropped += 1;
132
176
  continue;
133
177
  }
134
- if (isReticleOverlay(node))
178
+ if (isUnobserved(node)) {
135
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
+ }
136
195
  const mounted = describeMount(node);
137
- if (mounted === undefined)
196
+ if (mounted === undefined) {
138
197
  continue;
198
+ }
139
199
  const { role, name } = mounted;
140
200
  added += 1;
141
201
  const ref = refs.refFor(mounted.el);
@@ -143,31 +203,34 @@ export function installDom(emit) {
143
203
  if (DIALOG_ROLES.has(role) ||
144
204
  LIVE_ROLES.has(role) ||
145
205
  'true' === node.getAttribute('aria-modal')) {
146
- if (isVisible(node))
206
+ if (isVisible(node)) {
147
207
  emit(EventType.VISIBLE_SHOWN, { role, name }, ref);
208
+ }
148
209
  }
149
210
  }
150
211
  for (const node of record.removedNodes) {
151
- if (!isElement(node))
212
+ if (!isElement(node)) {
152
213
  continue;
214
+ }
153
215
  if (removed >= MAX_PER_BATCH) {
154
216
  dropped += 1;
155
217
  continue;
156
218
  }
157
- if (isReticleOverlay(node) ||
158
- (isElement(record.target) && isReticleOverlay(record.target))) {
219
+ if (isUnobserved(node) || (isElement(record.target) && isUnobserved(record.target))) {
159
220
  continue;
160
221
  }
161
222
  const gone = describeMount(node);
162
- if (gone === undefined)
223
+ if (gone === undefined) {
163
224
  continue;
225
+ }
164
226
  const { role, name } = gone;
165
227
  removed += 1;
166
228
  emit(EventType.DOM_REMOVED, { role, name, region: regionKeyOf(record.target) });
167
229
  }
168
230
  }
169
- if (dropped > 0)
231
+ if (dropped > 0) {
170
232
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
233
+ }
171
234
  });
172
235
  const options = {
173
236
  subtree: true,
@@ -179,8 +242,9 @@ export function installDom(emit) {
179
242
  characterDataOldValue: true,
180
243
  };
181
244
  observer.observe(document.documentElement, options);
182
- for (const root of capturedRoots())
245
+ for (const root of capturedRoots()) {
183
246
  observer.observe(root, options);
247
+ }
184
248
  const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
185
249
  const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
186
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,14 +1,34 @@
1
1
  import { readTestId } from '../dom/addressing/testid-attr.js';
2
- import { DEFAULT_TESTID_ATTR, EventType, isSensitiveKey } from '@reticlehq/core';
3
- import { getAccessibleName } from '../dom/a11y.js';
2
+ import { EventType, FieldChangeField, FieldChangeKind } from '@reticlehq/core';
3
+ import { getAccessibleName, isSensitiveField } from '../dom/a11y.js';
4
4
  const MAX_VALUE_LEN = 160;
5
5
  const BURST_MS = 150;
6
- const PAYMENT_AUTOCOMPLETE = /^(cc-|current-password|new-password)/i;
6
+ const CHECKBOX_TYPE = 'checkbox';
7
+ const RADIO_TYPE = 'radio';
8
+ let settledEmit;
7
9
  function isField(target) {
8
10
  return (target instanceof HTMLInputElement ||
9
11
  target instanceof HTMLTextAreaElement ||
10
12
  target instanceof HTMLSelectElement);
11
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
+ }
12
32
  function fieldName(element) {
13
33
  const testid = readTestId(element);
14
34
  if (null !== testid && testid.length > 0)
@@ -21,38 +41,102 @@ function fieldName(element) {
21
41
  const id = element.getAttribute('id');
22
42
  return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
23
43
  }
24
- function isSecret(element) {
25
- if (element instanceof HTMLInputElement && 'password' === element.type)
26
- return true;
27
- const hint = element.getAttribute('autocomplete');
28
- if (null !== hint && PAYMENT_AUTOCOMPLETE.test(hint))
29
- return true;
30
- for (const key of [
31
- element.name,
32
- readTestId(element),
33
- element.getAttribute(DEFAULT_TESTID_ATTR),
34
- element.getAttribute('id'),
35
- ]) {
36
- if (null !== key && key.length > 0 && isSensitiveKey(key))
37
- return true;
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;
38
58
  }
39
- return false;
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));
40
131
  }
41
132
  export function installField(emit) {
42
133
  const ac = new AbortController();
43
134
  const { signal } = ac;
135
+ const previousEmit = settledEmit;
136
+ settledEmit = emit;
44
137
  const pending = new Map();
45
138
  const report = (element, kind) => {
46
- const value = element.value;
47
- const secret = isSecret(element);
48
- emit(EventType.FIELD_CHANGE, {
49
- field: fieldName(element),
50
- kind,
51
- length: value.length,
52
- ...(secret
53
- ? { redacted: true }
54
- : { value: value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value }),
55
- });
139
+ emit(EventType.FIELD_CHANGE, fieldPayload(element, kind, readingOf(element)));
56
140
  };
57
141
  const clear = (element) => {
58
142
  const timer = pending.get(element);
@@ -67,7 +151,7 @@ export function installField(emit) {
67
151
  clear(element);
68
152
  pending.set(element, setTimeout(() => {
69
153
  pending.delete(element);
70
- report(element, 'input');
154
+ report(element, FieldChangeKind.INPUT);
71
155
  }, BURST_MS));
72
156
  };
73
157
  const onChange = (event) => {
@@ -75,7 +159,7 @@ export function installField(emit) {
75
159
  if (!isField(element))
76
160
  return;
77
161
  clear(element);
78
- report(element, 'change');
162
+ report(element, FieldChangeKind.CHANGE);
79
163
  };
80
164
  document.addEventListener('input', onInput, { signal, capture: true });
81
165
  document.addEventListener('change', onChange, { signal, capture: true });
@@ -83,6 +167,8 @@ export function installField(emit) {
83
167
  for (const timer of pending.values())
84
168
  clearTimeout(timer);
85
169
  pending.clear();
170
+ if (settledEmit === emit)
171
+ settledEmit = previousEmit;
86
172
  ac.abort();
87
173
  };
88
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;