@reticlehq/browser 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/actions-dom.d.ts +2 -1
- package/dist/actions/actions-dom.js +28 -41
- package/dist/actions/actions.d.ts +0 -1
- package/dist/actions/actions.js +13 -7
- package/dist/actions/value-input.js +14 -5
- package/dist/connect-options.d.ts +1 -0
- package/dist/dom/addressing/source.d.ts +2 -1
- package/dist/dom/dom-ignore.d.ts +1 -0
- package/dist/dom/dom-ignore.js +13 -0
- package/dist/dom/query.js +28 -9
- package/dist/dom/snapshot.d.ts +1 -1
- package/dist/observers/console.js +38 -13
- package/dist/observers/departure.js +16 -8
- package/dist/observers/dom.js +3 -1
- package/dist/observers/download.js +3 -2
- package/dist/observers/error-repeats.d.ts +18 -0
- package/dist/observers/error-repeats.js +41 -0
- package/dist/observers/field.d.ts +2 -0
- package/dist/observers/field.js +86 -0
- package/dist/observers/frames.js +3 -4
- package/dist/observers/install-all.js +2 -0
- package/dist/observers/net-detail/network-body.d.ts +2 -0
- package/dist/observers/net-detail/network-body.js +24 -7
- package/dist/observers/network.js +49 -8
- package/dist/observers/route.js +4 -4
- package/dist/observers/state.js +18 -8
- package/dist/presenter/carousel/carousel.d.ts +13 -0
- package/dist/presenter/carousel/carousel.js +126 -0
- package/dist/presenter/carousel/offer-card.d.ts +21 -0
- package/dist/presenter/carousel/offer-card.js +55 -0
- package/dist/presenter/carousel/panel-slides.d.ts +7 -0
- package/dist/presenter/carousel/panel-slides.js +10 -0
- package/dist/presenter/carousel/talk-card.d.ts +8 -0
- package/dist/presenter/carousel/talk-card.js +20 -0
- package/dist/presenter/chrome/presenter-log.d.ts +2 -1
- package/dist/presenter/chrome/presenter-log.js +14 -3
- package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -1
- package/dist/presenter/chrome/presenter-report-copy.js +1 -1
- package/dist/presenter/hud-telemetry.d.ts +3 -0
- package/dist/presenter/hud-telemetry.js +87 -0
- package/dist/presenter/presenter-account.js +1 -1
- package/dist/presenter/presenter-config.d.ts +5 -1
- package/dist/presenter/presenter-config.js +4 -1
- package/dist/presenter/presenter-controls.d.ts +1 -0
- package/dist/presenter/presenter-controls.js +12 -1
- package/dist/presenter/presenter-report-styles.js +4 -0
- package/dist/presenter/presenter-report.js +8 -7
- package/dist/presenter/presenter-settings-styles.js +2 -1
- package/dist/presenter/presenter-settings.d.ts +6 -4
- package/dist/presenter/presenter-settings.js +51 -5
- package/dist/presenter/presenter-shell-styles.js +16 -0
- package/dist/presenter/presenter-shell.d.ts +7 -0
- package/dist/presenter/presenter-shell.js +65 -5
- package/dist/presenter/presenter-styles.js +6 -0
- package/dist/presenter/presenter-workspace.d.ts +0 -7
- package/dist/presenter/presenter-workspace.js +7 -7
- package/dist/presenter/presenter.js +46 -10
- package/dist/presenter/tour/tour-view.js +7 -3
- package/dist/presenter/tour/tour.d.ts +3 -0
- package/dist/presenter/tour/tour.js +26 -2
- package/dist/recorder/recorder.js +25 -22
- package/dist/reticle-inject.d.ts +2 -0
- package/dist/reticle-inject.js +1337 -0
- package/dist/reticle.js +11 -1
- package/dist/review/annotator.d.ts +3 -0
- package/dist/review/annotator.js +37 -37
- package/dist/review/marks-for-agent.d.ts +9 -0
- package/dist/review/marks-for-agent.js +12 -0
- package/dist/util/captured-method.d.ts +0 -1
- package/dist/util/captured-method.js +1 -1
- package/package.json +8 -4
|
@@ -4,11 +4,12 @@ interface ClickGeometry {
|
|
|
4
4
|
scrolledIntoView: boolean;
|
|
5
5
|
}
|
|
6
6
|
export declare const NO_GEOMETRY: ClickGeometry;
|
|
7
|
+
export declare function mouseEventFor(el: Element, type: string, init?: MouseEventInit): MouseEvent;
|
|
7
8
|
export declare function fireClickSequence(el: HTMLElement, hold?: {
|
|
8
9
|
ms: number;
|
|
9
10
|
sleep: (ms: number) => Promise<void>;
|
|
10
11
|
now: () => number;
|
|
11
|
-
}): Promise<{
|
|
12
|
+
}, detail?: number): Promise<{
|
|
12
13
|
prevented: boolean;
|
|
13
14
|
heldMs: number;
|
|
14
15
|
}>;
|
|
@@ -7,11 +7,21 @@ export const NO_GEOMETRY = {
|
|
|
7
7
|
occludedBy: null,
|
|
8
8
|
scrolledIntoView: false,
|
|
9
9
|
};
|
|
10
|
-
export
|
|
10
|
+
export function mouseEventFor(el, type, init = {}) {
|
|
11
|
+
return new MouseEvent(type, { ...init, view: el.ownerDocument.defaultView });
|
|
12
|
+
}
|
|
13
|
+
function pointerEventFor(el, type, init) {
|
|
14
|
+
const full = { ...init, view: el.ownerDocument.defaultView };
|
|
15
|
+
return 'function' === typeof PointerEvent
|
|
16
|
+
? new PointerEvent(type, full)
|
|
17
|
+
: new MouseEvent(type, full);
|
|
18
|
+
}
|
|
19
|
+
export async function fireClickSequence(el, hold, detail) {
|
|
11
20
|
const doc = el.ownerDocument;
|
|
12
21
|
const from = doc.activeElement ?? doc.body;
|
|
22
|
+
const init = { bubbles: true, cancelable: true, detail: detail ?? 0 };
|
|
13
23
|
firePointer(el, 'pointerdown', from);
|
|
14
|
-
asSyntheticInput(() => el.dispatchEvent(
|
|
24
|
+
asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', init)));
|
|
15
25
|
if (el.tabIndex >= 0 && 'function' === typeof el.focus)
|
|
16
26
|
el.focus();
|
|
17
27
|
let heldMs = 0;
|
|
@@ -21,8 +31,8 @@ export async function fireClickSequence(el, hold) {
|
|
|
21
31
|
heldMs = hold.now() - startedAt;
|
|
22
32
|
}
|
|
23
33
|
firePointer(el, 'pointerup', from);
|
|
24
|
-
asSyntheticInput(() => el.dispatchEvent(
|
|
25
|
-
const notPrevented = asSyntheticInput(() => el.dispatchEvent(
|
|
34
|
+
asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', init)));
|
|
35
|
+
const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', init)));
|
|
26
36
|
return { prevented: !notPrevented, heldMs };
|
|
27
37
|
}
|
|
28
38
|
function isMeasurable(rect) {
|
|
@@ -57,24 +67,10 @@ export function clickGeometry(el) {
|
|
|
57
67
|
return { ...hitTest(el, rect), scrolledIntoView };
|
|
58
68
|
}
|
|
59
69
|
export function firePointer(el, type, relatedTarget = null) {
|
|
60
|
-
asSyntheticInput(() => {
|
|
61
|
-
if ('function' === typeof PointerEvent) {
|
|
62
|
-
el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
|
|
66
|
-
}
|
|
67
|
-
});
|
|
70
|
+
asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: true, cancelable: true, relatedTarget })));
|
|
68
71
|
}
|
|
69
72
|
export function firePointerNonBubbling(el, type, relatedTarget = null) {
|
|
70
|
-
asSyntheticInput(() => {
|
|
71
|
-
if ('function' === typeof PointerEvent) {
|
|
72
|
-
el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
el.dispatchEvent(new MouseEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
|
|
76
|
-
}
|
|
77
|
-
});
|
|
73
|
+
asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
|
|
78
74
|
}
|
|
79
75
|
function makeDataTransfer(data) {
|
|
80
76
|
if (typeof DataTransfer !== 'function')
|
|
@@ -117,12 +113,9 @@ export async function dragElement(source, target, data) {
|
|
|
117
113
|
button: 0,
|
|
118
114
|
...(related !== undefined ? { relatedTarget: related } : {}),
|
|
119
115
|
};
|
|
120
|
-
|
|
121
|
-
el
|
|
122
|
-
|
|
123
|
-
else {
|
|
124
|
-
el.dispatchEvent(new MouseEvent(type, init));
|
|
125
|
-
}
|
|
116
|
+
el.dispatchEvent(type.startsWith('pointer')
|
|
117
|
+
? pointerEventFor(el, type, { ...init, pointerId: 1, isPrimary: true })
|
|
118
|
+
: mouseEventFor(el, type, init));
|
|
126
119
|
};
|
|
127
120
|
const sourceRect = source.getBoundingClientRect();
|
|
128
121
|
const destRect = target !== null ? dest.getBoundingClientRect() : null;
|
|
@@ -190,24 +183,18 @@ export async function fireTapSequence(el, hold) {
|
|
|
190
183
|
}
|
|
191
184
|
asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchend', [])));
|
|
192
185
|
firePointerTouch(el, 'pointerup');
|
|
193
|
-
const clicked = asSyntheticInput(() => el.dispatchEvent(
|
|
186
|
+
const clicked = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
|
|
194
187
|
return { prevented: !clicked, heldMs };
|
|
195
188
|
}
|
|
196
189
|
function firePointerTouch(el, type) {
|
|
197
|
-
asSyntheticInput(() => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
height: 23,
|
|
206
|
-
}));
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
210
|
-
});
|
|
190
|
+
asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, {
|
|
191
|
+
bubbles: true,
|
|
192
|
+
cancelable: true,
|
|
193
|
+
pointerType: 'touch',
|
|
194
|
+
isPrimary: true,
|
|
195
|
+
width: 23,
|
|
196
|
+
height: 23,
|
|
197
|
+
})));
|
|
211
198
|
}
|
|
212
199
|
function touchListFor(el) {
|
|
213
200
|
if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect)
|
|
@@ -34,7 +34,6 @@ interface ActionResult {
|
|
|
34
34
|
};
|
|
35
35
|
warning?: string;
|
|
36
36
|
}
|
|
37
|
-
export declare const MAX_HOLD_MS = 30000;
|
|
38
37
|
export declare const ZOOM_NEEDS_REAL_BROWSER_MSG: string;
|
|
39
38
|
export declare function executeAction(ref: string, action: string, args?: Record<string, unknown>): Promise<ActionResult>;
|
|
40
39
|
export declare function dispatchWebMcp(tool: string, params: Record<string, unknown>, confirmDangerous?: boolean): Promise<unknown>;
|
package/dist/actions/actions.js
CHANGED
|
@@ -124,7 +124,7 @@ function assertActionAllowed(el, action, args) {
|
|
|
124
124
|
throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
|
|
127
|
+
import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, } from './actions-dom.js';
|
|
128
128
|
import { scrollFrom } from './scroll.js';
|
|
129
129
|
function enabledOf(el) {
|
|
130
130
|
return !getStates(el).includes(ElementState.DISABLED);
|
|
@@ -141,7 +141,7 @@ function activeRef(el) {
|
|
|
141
141
|
return null;
|
|
142
142
|
return refs.refFor(active);
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
const MAX_HOLD_MS = 30_000;
|
|
145
145
|
function clampHold(raw) {
|
|
146
146
|
if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
|
|
147
147
|
return 0;
|
|
@@ -167,16 +167,18 @@ function asFiniteNumber(raw) {
|
|
|
167
167
|
async function dispatchOther(el, action, args) {
|
|
168
168
|
switch (action) {
|
|
169
169
|
case ActionType.DBLCLICK:
|
|
170
|
-
|
|
170
|
+
await fireClickSequence(el, undefined, 1);
|
|
171
|
+
await fireClickSequence(el, undefined, 2);
|
|
172
|
+
return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true, detail: 2 })));
|
|
171
173
|
case ActionType.HOVER: {
|
|
172
174
|
const doc = el.ownerDocument;
|
|
173
175
|
const from = doc.activeElement ?? doc.body;
|
|
174
176
|
firePointer(el, 'pointerover', from);
|
|
175
|
-
el.dispatchEvent(
|
|
177
|
+
el.dispatchEvent(mouseEventFor(el, 'mouseover', { bubbles: true, relatedTarget: from }));
|
|
176
178
|
firePointerNonBubbling(el, 'pointerenter', from);
|
|
177
|
-
el.dispatchEvent(
|
|
179
|
+
el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from }));
|
|
178
180
|
firePointer(el, 'pointermove', from);
|
|
179
|
-
const moved = el.dispatchEvent(
|
|
181
|
+
const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
|
|
180
182
|
const holdMs = clampHold(args['holdMs']);
|
|
181
183
|
if (holdMs > 0)
|
|
182
184
|
await sleep(holdMs);
|
|
@@ -252,7 +254,11 @@ async function dispatchOther(el, action, args) {
|
|
|
252
254
|
}
|
|
253
255
|
if (alreadyAtCheckedState(el, action))
|
|
254
256
|
return false;
|
|
255
|
-
const event =
|
|
257
|
+
const event = mouseEventFor(el, 'click', {
|
|
258
|
+
bubbles: true,
|
|
259
|
+
cancelable: true,
|
|
260
|
+
composed: true,
|
|
261
|
+
});
|
|
256
262
|
const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
|
|
257
263
|
return !notPrevented || event.defaultPrevented;
|
|
258
264
|
}
|
|
@@ -13,12 +13,21 @@ export function setNativeValue(el, value) {
|
|
|
13
13
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
14
14
|
return !notPrevented;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
function isRichText(el) {
|
|
17
17
|
const flag = el.getAttribute('contenteditable');
|
|
18
|
-
if (el.isContentEditable || (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
if (el.isContentEditable || (null !== flag && 'false' !== flag))
|
|
19
|
+
return true;
|
|
20
|
+
if ('textbox' === el.getAttribute('role'))
|
|
21
|
+
return true;
|
|
22
|
+
if (!('editContext' in el))
|
|
23
|
+
return false;
|
|
24
|
+
const surface = el;
|
|
25
|
+
return null !== surface.editContext && undefined !== surface.editContext;
|
|
26
|
+
}
|
|
27
|
+
export function assertNotRichText(el, action) {
|
|
28
|
+
if (!isRichText(el))
|
|
29
|
+
return;
|
|
30
|
+
throw new Error(`cannot ${action} a contenteditable or EditContext editor; use press or an input`);
|
|
22
31
|
}
|
|
23
32
|
export function assertEditable(el, action) {
|
|
24
33
|
if (el.disabled) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
interface SourceLocation {
|
|
2
2
|
file: string;
|
|
3
3
|
line: number;
|
|
4
4
|
}
|
|
@@ -7,3 +7,4 @@ export declare function sourceFromDom(el: Element): SourceLocation | undefined;
|
|
|
7
7
|
export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
|
|
8
8
|
export declare function documentHasSourceStamps(doc: Document): boolean;
|
|
9
9
|
export declare function formatSource(source: SourceLocation | undefined): string | undefined;
|
|
10
|
+
export {};
|
package/dist/dom/dom-ignore.d.ts
CHANGED
|
@@ -5,3 +5,4 @@ export declare function setIgnoreSelectors(selectors: string[]): void;
|
|
|
5
5
|
export declare function isReticleOverlay(el: Element): boolean;
|
|
6
6
|
export declare function isReticleUi(node: Element | null): boolean;
|
|
7
7
|
export declare function isIgnored(el: Element): boolean;
|
|
8
|
+
export declare function appModalOpen(doc: Document): boolean;
|
package/dist/dom/dom-ignore.js
CHANGED
|
@@ -22,3 +22,16 @@ export function isIgnored(el) {
|
|
|
22
22
|
const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
|
|
23
23
|
return el.closest(sel) !== null;
|
|
24
24
|
}
|
|
25
|
+
const APP_MODAL = 'dialog[open], [aria-modal="true"]';
|
|
26
|
+
export function appModalOpen(doc) {
|
|
27
|
+
for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
|
|
28
|
+
if (el.closest(RETICLE_OVERLAY) !== null)
|
|
29
|
+
continue;
|
|
30
|
+
if (el instanceof HTMLElement && (el.hidden || null !== el.closest('[hidden]')))
|
|
31
|
+
continue;
|
|
32
|
+
if (el instanceof HTMLElement && 'none' === getComputedStyle(el).display)
|
|
33
|
+
continue;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
package/dist/dom/query.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
2
|
-
import { isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
|
|
2
|
+
import { isElement, isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
|
|
3
3
|
import { capturedRootOf } from './shadow-registry.js';
|
|
4
4
|
import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
|
|
5
5
|
import { isIgnored, isReticleOverlay } from './dom-ignore.js';
|
|
@@ -27,20 +27,35 @@ function fuzzyVisibleText(actual, expected) {
|
|
|
27
27
|
function exactVisibleText(actual, expected) {
|
|
28
28
|
return normaliseVisibleText(actual) === normaliseVisibleText(expected);
|
|
29
29
|
}
|
|
30
|
+
const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
|
|
31
|
+
function isNonRendered(el) {
|
|
32
|
+
return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
|
|
33
|
+
}
|
|
30
34
|
function directText(el) {
|
|
31
35
|
if (isInput(el)) {
|
|
32
36
|
const type = el.type.toLowerCase();
|
|
33
37
|
if ('submit' === type || 'button' === type || 'reset' === type)
|
|
34
38
|
return el.value;
|
|
35
39
|
}
|
|
36
|
-
|
|
37
|
-
if ('script' === tag || 'style' === tag)
|
|
40
|
+
if (isNonRendered(el))
|
|
38
41
|
return '';
|
|
39
42
|
return Array.from(el.childNodes)
|
|
40
43
|
.filter((node) => Node.TEXT_NODE === node.nodeType)
|
|
41
44
|
.map((node) => node.textContent ?? '')
|
|
42
45
|
.join('');
|
|
43
46
|
}
|
|
47
|
+
function visibleTextOf(el, memo) {
|
|
48
|
+
if (isNonRendered(el) || isIgnored(el) || !isVisible(el, memo))
|
|
49
|
+
return '';
|
|
50
|
+
let text = '';
|
|
51
|
+
for (const node of Array.from(el.childNodes)) {
|
|
52
|
+
if (Node.TEXT_NODE === node.nodeType)
|
|
53
|
+
text += node.textContent ?? '';
|
|
54
|
+
else if (isElement(node))
|
|
55
|
+
text += visibleTextOf(node, memo);
|
|
56
|
+
}
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
44
59
|
function semanticNameTarget(el) {
|
|
45
60
|
if (isInput(el) || isTextArea(el) || isSelect(el))
|
|
46
61
|
return true;
|
|
@@ -205,7 +220,8 @@ function findCandidates(query) {
|
|
|
205
220
|
if (true === query.self) {
|
|
206
221
|
if (query.scope === undefined)
|
|
207
222
|
return { candidates: [], scopeMissing };
|
|
208
|
-
if (query.text !== undefined &&
|
|
223
|
+
if (query.text !== undefined &&
|
|
224
|
+
!fuzzyVisibleText(visibleTextOf(container, new Map()), query.text)) {
|
|
209
225
|
return { candidates: [], scopeMissing };
|
|
210
226
|
}
|
|
211
227
|
return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
|
|
@@ -342,10 +358,11 @@ function buildPresentRegions(query) {
|
|
|
342
358
|
function splitTextOwner(container, wanted) {
|
|
343
359
|
let best;
|
|
344
360
|
let bestDepth = -1;
|
|
361
|
+
const memo = new Map();
|
|
345
362
|
for (const el of elementsUnder(container)) {
|
|
346
363
|
if (isIgnored(el))
|
|
347
364
|
continue;
|
|
348
|
-
if (!fuzzyVisibleText(el
|
|
365
|
+
if (!fuzzyVisibleText(visibleTextOf(el, memo), wanted))
|
|
349
366
|
continue;
|
|
350
367
|
let depth = 0;
|
|
351
368
|
for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
|
|
@@ -392,15 +409,16 @@ function buildEmptyHint(query) {
|
|
|
392
409
|
const container = resolveContainer(query.scope).container ?? document.body;
|
|
393
410
|
const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
|
|
394
411
|
const present = [];
|
|
412
|
+
const seen = new Set();
|
|
395
413
|
for (const el of Array.from(all)) {
|
|
396
414
|
if (isIgnored(el))
|
|
397
415
|
continue;
|
|
398
416
|
const id = el.getAttribute(TESTID_ATTR);
|
|
399
|
-
if (id
|
|
417
|
+
if (null === id || 0 === id.length || seen.has(id))
|
|
418
|
+
continue;
|
|
419
|
+
seen.add(id);
|
|
420
|
+
if (present.length < MAX_PRESENT_TESTIDS)
|
|
400
421
|
present.push(id);
|
|
401
|
-
if (present.length >= MAX_PRESENT_TESTIDS)
|
|
402
|
-
break;
|
|
403
|
-
}
|
|
404
422
|
}
|
|
405
423
|
const registered = declaredTestids();
|
|
406
424
|
const knownEmptyState = present.some((id) => registered.includes(id));
|
|
@@ -408,6 +426,7 @@ function buildEmptyHint(query) {
|
|
|
408
426
|
const hint = {
|
|
409
427
|
route,
|
|
410
428
|
presentTestids: present,
|
|
429
|
+
...(seen.size > present.length ? { presentTestidsTotal: seen.size } : {}),
|
|
411
430
|
presentRegions: buildPresentRegions(query),
|
|
412
431
|
knownEmptyState,
|
|
413
432
|
};
|
package/dist/dom/snapshot.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { EventType, 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';
|
|
5
|
+
import { createRepeatLimiter } from './error-repeats.js';
|
|
6
|
+
const CSP_VIOLATION_EVENT = 'securitypolicyviolation';
|
|
7
|
+
const REPORT_ONLY = 'report';
|
|
5
8
|
const METHOD_EVENT = {
|
|
6
9
|
log: EventType.CONSOLE_LOG,
|
|
7
10
|
warn: EventType.CONSOLE_WARN,
|
|
@@ -54,6 +57,16 @@ export function installConsole(emit) {
|
|
|
54
57
|
patched.set(method, wrapper);
|
|
55
58
|
console[method] = wrapper;
|
|
56
59
|
}
|
|
60
|
+
const repeats = createRepeatLimiter();
|
|
61
|
+
const emitError = (payload) => {
|
|
62
|
+
const decision = repeats.admit(payload);
|
|
63
|
+
if (!decision.emit)
|
|
64
|
+
return;
|
|
65
|
+
emit(EventType.ERROR_UNCAUGHT, {
|
|
66
|
+
...payload,
|
|
67
|
+
...(undefined === decision.repeats ? {} : { repeats: decision.repeats }),
|
|
68
|
+
});
|
|
69
|
+
};
|
|
57
70
|
const onError = (event) => {
|
|
58
71
|
if (!(event instanceof ErrorEvent)) {
|
|
59
72
|
const target = event.target;
|
|
@@ -62,43 +75,55 @@ export function installConsole(emit) {
|
|
|
62
75
|
const tag = target.tagName.toLowerCase();
|
|
63
76
|
const url = target.getAttribute('src') ?? target.getAttribute('href') ?? '';
|
|
64
77
|
observeSafely(() => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
kind: 'resource',
|
|
68
|
-
...(0 === url.length ? {} : { source: url }),
|
|
69
|
-
});
|
|
78
|
+
const message = `<${tag}> failed to load${0 === url.length ? '' : `: ${url}`}`;
|
|
79
|
+
emitError({ message, kind: 'resource', ...(0 === url.length ? {} : { source: url }) });
|
|
70
80
|
});
|
|
71
81
|
return;
|
|
72
82
|
}
|
|
73
83
|
observeSafely(() => {
|
|
74
84
|
const stack = capStack(event.error instanceof Error ? event.error.stack : undefined);
|
|
75
|
-
|
|
85
|
+
const payload = {
|
|
76
86
|
message: event.message,
|
|
77
87
|
source: event.filename,
|
|
78
88
|
line: event.lineno,
|
|
79
89
|
...(stack === undefined ? {} : { stack }),
|
|
80
|
-
}
|
|
90
|
+
};
|
|
91
|
+
emitError(payload);
|
|
81
92
|
});
|
|
82
93
|
};
|
|
83
94
|
const onRejection = (event) => {
|
|
84
95
|
observeSafely(() => {
|
|
85
96
|
const reason = event.reason;
|
|
86
97
|
const stack = capStack(reason instanceof Error ? reason.stack : undefined);
|
|
87
|
-
|
|
98
|
+
const payload = {
|
|
88
99
|
message: reason instanceof Error ? reason.message : String(reason),
|
|
89
100
|
kind: 'unhandledrejection',
|
|
90
101
|
...(stack === undefined ? {} : { stack }),
|
|
91
|
-
}
|
|
102
|
+
};
|
|
103
|
+
emitError(payload);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const onViolation = (event) => {
|
|
107
|
+
const what = 0 === event.blockedURI.length ? 'inline content' : event.blockedURI;
|
|
108
|
+
const reportOnly = REPORT_ONLY === event.disposition;
|
|
109
|
+
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}`,
|
|
113
|
+
kind: CSP_VIOLATION_EVENT,
|
|
114
|
+
...(0 === event.blockedURI.length ? {} : { source: event.blockedURI }),
|
|
92
115
|
});
|
|
93
116
|
};
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
const listeners = new AbortController();
|
|
118
|
+
const { signal } = listeners;
|
|
119
|
+
window.addEventListener('error', onError, { capture: true, signal });
|
|
120
|
+
window.addEventListener('unhandledrejection', onRejection, { signal });
|
|
121
|
+
window.addEventListener(CSP_VIOLATION_EVENT, onViolation, { capture: true, signal });
|
|
96
122
|
return () => {
|
|
97
123
|
for (const [method, original] of originals) {
|
|
98
124
|
if (console[method] === patched.get(method))
|
|
99
125
|
console[method] = original;
|
|
100
126
|
}
|
|
101
|
-
|
|
102
|
-
window.removeEventListener('unhandledrejection', onRejection);
|
|
127
|
+
listeners.abort();
|
|
103
128
|
};
|
|
104
129
|
}
|
|
@@ -35,15 +35,23 @@ export function installDeparture(emit) {
|
|
|
35
35
|
return;
|
|
36
36
|
const href = anchor.getAttribute('href') ?? '';
|
|
37
37
|
const here = document.location.href;
|
|
38
|
-
|
|
38
|
+
const download = anchor.hasAttribute('download');
|
|
39
|
+
if (!isDeparture(href, download, here))
|
|
39
40
|
return;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
const url = new URL(href, here).toString();
|
|
42
|
+
queueMicrotask(() => {
|
|
43
|
+
observeSafely(() => {
|
|
44
|
+
if (event.defaultPrevented)
|
|
45
|
+
return;
|
|
46
|
+
seq += 1;
|
|
47
|
+
emit(EventType.NET_PENDING, {
|
|
48
|
+
id: `nav-${String(seq)}`,
|
|
49
|
+
method: 'GET',
|
|
50
|
+
url,
|
|
51
|
+
initiator: NetInitiator.NAVIGATION,
|
|
52
|
+
...(download ? { download: true } : {}),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
47
55
|
});
|
|
48
56
|
});
|
|
49
57
|
};
|
package/dist/observers/dom.js
CHANGED
|
@@ -153,8 +153,10 @@ export function installDom(emit) {
|
|
|
153
153
|
dropped += 1;
|
|
154
154
|
continue;
|
|
155
155
|
}
|
|
156
|
-
if (isReticleOverlay(node)
|
|
156
|
+
if (isReticleOverlay(node) ||
|
|
157
|
+
(isElement(record.target) && isReticleOverlay(record.target))) {
|
|
157
158
|
continue;
|
|
159
|
+
}
|
|
158
160
|
const gone = describeMount(node);
|
|
159
161
|
if (gone === undefined)
|
|
160
162
|
continue;
|
|
@@ -7,6 +7,7 @@ const PREVIEW_CHARS = 2000;
|
|
|
7
7
|
export function installDownload(emit, opts = {}) {
|
|
8
8
|
if (typeof URL.createObjectURL !== 'function')
|
|
9
9
|
return () => undefined;
|
|
10
|
+
const ac = new AbortController();
|
|
10
11
|
const objectUrl = URL.createObjectURL.bind(URL);
|
|
11
12
|
const known = new Map();
|
|
12
13
|
const originalCreate = captureMethod(URL, 'createObjectURL');
|
|
@@ -58,7 +59,7 @@ export function installDownload(emit, opts = {}) {
|
|
|
58
59
|
report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
|
|
59
60
|
});
|
|
60
61
|
};
|
|
61
|
-
document.addEventListener('click', onClick, true);
|
|
62
|
+
document.addEventListener('click', onClick, { capture: true, signal: ac.signal });
|
|
62
63
|
const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
|
|
63
64
|
const patchedClick = function reticleObservedAnchorClick() {
|
|
64
65
|
const pending = observeValue(() => this.hasAttribute('download')
|
|
@@ -75,7 +76,7 @@ export function installDownload(emit, opts = {}) {
|
|
|
75
76
|
};
|
|
76
77
|
HTMLAnchorElement.prototype.click = patchedClick;
|
|
77
78
|
return () => {
|
|
78
|
-
|
|
79
|
+
ac.abort();
|
|
79
80
|
if (URL.createObjectURL === patchedCreate)
|
|
80
81
|
URL.createObjectURL = originalCreate;
|
|
81
82
|
if (HTMLAnchorElement.prototype.click === patchedClick) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const REPEAT_BURST = 3;
|
|
2
|
+
export declare const MAX_TRACKED_FINGERPRINTS = 256;
|
|
3
|
+
export interface RepeatDecision {
|
|
4
|
+
readonly emit: boolean;
|
|
5
|
+
readonly repeats?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ErrorIdentity {
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly source?: string;
|
|
10
|
+
readonly line?: number;
|
|
11
|
+
readonly kind?: string;
|
|
12
|
+
readonly stack?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function fingerprintError(identity: ErrorIdentity): string;
|
|
15
|
+
export interface RepeatLimiter {
|
|
16
|
+
admit(identity: ErrorIdentity): RepeatDecision;
|
|
17
|
+
}
|
|
18
|
+
export declare function createRepeatLimiter(burst?: number, maxTracked?: number): RepeatLimiter;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { fnv1a } from '@reticlehq/core';
|
|
2
|
+
export const REPEAT_BURST = 3;
|
|
3
|
+
export const MAX_TRACKED_FINGERPRINTS = 256;
|
|
4
|
+
const PASS = { emit: true };
|
|
5
|
+
const DROP = { emit: false };
|
|
6
|
+
function isCheckpoint(count) {
|
|
7
|
+
if (count < 10)
|
|
8
|
+
return false;
|
|
9
|
+
let n = count;
|
|
10
|
+
while (0 === n % 10)
|
|
11
|
+
n /= 10;
|
|
12
|
+
return 1 === n;
|
|
13
|
+
}
|
|
14
|
+
export function fingerprintError(identity) {
|
|
15
|
+
return fnv1a([
|
|
16
|
+
identity.message,
|
|
17
|
+
identity.source ?? '',
|
|
18
|
+
undefined === identity.line ? '' : String(identity.line),
|
|
19
|
+
identity.kind ?? '',
|
|
20
|
+
identity.stack ?? '',
|
|
21
|
+
].join('\x00'));
|
|
22
|
+
}
|
|
23
|
+
export function createRepeatLimiter(burst = REPEAT_BURST, maxTracked = MAX_TRACKED_FINGERPRINTS) {
|
|
24
|
+
const counts = new Map();
|
|
25
|
+
return {
|
|
26
|
+
admit(identity) {
|
|
27
|
+
const key = fingerprintError(identity);
|
|
28
|
+
const seen = (counts.get(key) ?? 0) + 1;
|
|
29
|
+
counts.delete(key);
|
|
30
|
+
if (counts.size >= maxTracked) {
|
|
31
|
+
const oldest = counts.keys().next();
|
|
32
|
+
if (!(oldest.done ?? false))
|
|
33
|
+
counts.delete(oldest.value);
|
|
34
|
+
}
|
|
35
|
+
counts.set(key, seen);
|
|
36
|
+
if (seen <= burst)
|
|
37
|
+
return PASS;
|
|
38
|
+
return isCheckpoint(seen) ? { emit: true, repeats: seen } : DROP;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|