browser-commander 0.17.2 → 0.19.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/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -1
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +274 -0
- package/tests/helpers/trace-server.js +173 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +294 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orthogonal click activation options.
|
|
3
|
+
*
|
|
4
|
+
* `noAutoScroll: true` used to be translated into Playwright's `{ force: true }`,
|
|
5
|
+
* which skips actionability checks but still scrolls the element into view. The
|
|
6
|
+
* option therefore promised something the engine never delivered. These three
|
|
7
|
+
* axes are independent and each one means exactly what it says:
|
|
8
|
+
*
|
|
9
|
+
* - `activation`: how the click is delivered ('pointer' | 'dom')
|
|
10
|
+
* - `scroll`: what may happen to the scroll position ('auto' | 'preserve' | 'none')
|
|
11
|
+
* - `actionability`: whether engine pre-checks are skipped ('normal' | 'force')
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { TIMING } from '../core/constants.js';
|
|
15
|
+
import { createDeadline } from '../core/readiness.js';
|
|
16
|
+
|
|
17
|
+
/** How the click is delivered to the element. */
|
|
18
|
+
export const CLICK_ACTIVATION = Object.freeze({
|
|
19
|
+
/** Real pointer input at the element's click point. */
|
|
20
|
+
POINTER: 'pointer',
|
|
21
|
+
/** `HTMLElement.click()` - an untrusted event that skips pointer handlers. */
|
|
22
|
+
DOM: 'dom',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** What the click is allowed to do to the scroll position. */
|
|
26
|
+
export const CLICK_SCROLL = Object.freeze({
|
|
27
|
+
/** Let the engine scroll the element into view (default). */
|
|
28
|
+
AUTO: 'auto',
|
|
29
|
+
/** Allow scrolling, then restore the original scroll position. */
|
|
30
|
+
PRESERVE: 'preserve',
|
|
31
|
+
/** Never scroll; fail if the click cannot be delivered without scrolling. */
|
|
32
|
+
NONE: 'none',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Whether the engine's actionability pre-checks run. */
|
|
36
|
+
export const CLICK_ACTIONABILITY = Object.freeze({
|
|
37
|
+
NORMAL: 'normal',
|
|
38
|
+
/** Skip engine pre-checks. Does NOT disable scrolling. */
|
|
39
|
+
FORCE: 'force',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Thrown when `scroll: 'none'` cannot be honored.
|
|
44
|
+
*
|
|
45
|
+
* Failing loudly is the point: the previous behavior scrolled anyway and
|
|
46
|
+
* reported success, so callers who needed the viewport to stay put had no way
|
|
47
|
+
* to find out that it had moved.
|
|
48
|
+
*/
|
|
49
|
+
export class ScrollConstraintError extends Error {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} message - What made the constraint impossible to honor
|
|
52
|
+
* @param {Object} [detail] - Structured detail about the element and viewport
|
|
53
|
+
*/
|
|
54
|
+
constructor(message, detail = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'ScrollConstraintError';
|
|
57
|
+
this.detail = detail;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the activation options, applying the `noAutoScroll` compatibility
|
|
63
|
+
* mapping.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} options - Raw caller options
|
|
66
|
+
* @param {string} [options.activation] - Activation mode
|
|
67
|
+
* @param {string} [options.scroll] - Scroll policy
|
|
68
|
+
* @param {string} [options.actionability] - Actionability policy
|
|
69
|
+
* @param {boolean} [options.noAutoScroll] - Deprecated alias for `scroll: 'none'`
|
|
70
|
+
* @param {Function} [options.log] - Logger used for the deprecation notice
|
|
71
|
+
* @returns {{activation: string, scroll: string, actionability: string, deprecations: string[]}} Resolved options
|
|
72
|
+
*/
|
|
73
|
+
export function resolveActivationOptions(options = {}) {
|
|
74
|
+
const { activation, scroll, actionability, noAutoScroll, log } = options;
|
|
75
|
+
const deprecations = [];
|
|
76
|
+
|
|
77
|
+
let resolvedScroll = scroll;
|
|
78
|
+
|
|
79
|
+
if (noAutoScroll !== undefined) {
|
|
80
|
+
deprecations.push(
|
|
81
|
+
'noAutoScroll is deprecated; use scroll: "none" (no scrolling at all) ' +
|
|
82
|
+
'or actionability: "force" (skip engine pre-checks, scrolling still allowed)'
|
|
83
|
+
);
|
|
84
|
+
if (resolvedScroll === undefined) {
|
|
85
|
+
resolvedScroll = noAutoScroll ? CLICK_SCROLL.NONE : CLICK_SCROLL.AUTO;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolved = {
|
|
90
|
+
activation: activation ?? CLICK_ACTIVATION.POINTER,
|
|
91
|
+
scroll: resolvedScroll ?? CLICK_SCROLL.AUTO,
|
|
92
|
+
actionability: actionability ?? CLICK_ACTIONABILITY.NORMAL,
|
|
93
|
+
deprecations,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
assertOneOf('activation', resolved.activation, CLICK_ACTIVATION);
|
|
97
|
+
assertOneOf('scroll', resolved.scroll, CLICK_SCROLL);
|
|
98
|
+
assertOneOf('actionability', resolved.actionability, CLICK_ACTIONABILITY);
|
|
99
|
+
|
|
100
|
+
if (deprecations.length > 0 && log?.debug) {
|
|
101
|
+
deprecations.forEach((message) => log.debug(() => `⚠️ ${message}`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Start a click action: open one deadline and resolve the activation options.
|
|
109
|
+
*
|
|
110
|
+
* Both `clickElement` and `clickButton` need a clock and the same option
|
|
111
|
+
* resolution, and both must do it *before* anything can throw so that
|
|
112
|
+
* failures still report how long they took. The clock is the deadline's, so
|
|
113
|
+
* every later step - dispatch, probe, verification - measures against one
|
|
114
|
+
* monotonic budget rather than each starting a wall-clock timer of its own.
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} options - Raw caller options, as accepted by {@link resolveActivationOptions}
|
|
117
|
+
* @param {number} [options.timeout] - Total budget for the whole click
|
|
118
|
+
* @returns {{deadline: Object, elapsed: Function, activationOptions: Object}} Deadline, elapsed-ms reader and resolved options
|
|
119
|
+
*/
|
|
120
|
+
export function beginClickAction(options = {}) {
|
|
121
|
+
const { timeout = TIMING.VERIFICATION_TIMEOUT } = options;
|
|
122
|
+
const deadline = createDeadline({ timeout });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
deadline,
|
|
126
|
+
elapsed: () => deadline.elapsedMs(),
|
|
127
|
+
activationOptions: resolveActivationOptions(options),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertOneOf(name, value, allowed) {
|
|
132
|
+
const values = Object.values(allowed);
|
|
133
|
+
if (!values.includes(value)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`${name} must be one of ${values.map((v) => `'${v}'`).join(', ')}, got '${value}'`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Measure an element's click point and whether it is reachable right now.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} options - Configuration options
|
|
144
|
+
* @param {Object} options.adapter - Engine adapter
|
|
145
|
+
* @param {Object} options.locatorOrElement - Element or locator
|
|
146
|
+
* @returns {Promise<Object>} Geometry and hit-test result
|
|
147
|
+
*/
|
|
148
|
+
export function measureClickPoint(options = {}) {
|
|
149
|
+
const { adapter, locatorOrElement } = options;
|
|
150
|
+
|
|
151
|
+
return adapter.evaluateOnElement(locatorOrElement, (el) => {
|
|
152
|
+
const rect = el.getBoundingClientRect();
|
|
153
|
+
const x = rect.left + rect.width / 2;
|
|
154
|
+
const y = rect.top + rect.height / 2;
|
|
155
|
+
const inViewport =
|
|
156
|
+
rect.width > 0 &&
|
|
157
|
+
rect.height > 0 &&
|
|
158
|
+
x >= 0 &&
|
|
159
|
+
y >= 0 &&
|
|
160
|
+
x <= window.innerWidth &&
|
|
161
|
+
y <= window.innerHeight;
|
|
162
|
+
const hit = inViewport ? document.elementFromPoint(x, y) : null;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
x,
|
|
166
|
+
y,
|
|
167
|
+
width: rect.width,
|
|
168
|
+
height: rect.height,
|
|
169
|
+
top: rect.top,
|
|
170
|
+
left: rect.left,
|
|
171
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
172
|
+
scroll: { x: window.scrollX, y: window.scrollY },
|
|
173
|
+
inViewport,
|
|
174
|
+
hitsTarget: Boolean(hit && (hit === el || el.contains(hit))),
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read the current window scroll position.
|
|
181
|
+
*
|
|
182
|
+
* @param {Object} adapter - Engine adapter
|
|
183
|
+
* @returns {Promise<{x: number, y: number}>} Scroll offsets
|
|
184
|
+
*/
|
|
185
|
+
export async function readScrollPosition(adapter) {
|
|
186
|
+
if (typeof adapter?.evaluateOnPage !== 'function') {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
return await adapter.evaluateOnPage(() => ({
|
|
191
|
+
x: window.scrollX,
|
|
192
|
+
y: window.scrollY,
|
|
193
|
+
}));
|
|
194
|
+
} catch {
|
|
195
|
+
// Scroll position is evidence, not a precondition - never fail a click
|
|
196
|
+
// because we could not read it.
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Restore a previously captured scroll position.
|
|
203
|
+
*
|
|
204
|
+
* @param {Object} adapter - Engine adapter
|
|
205
|
+
* @param {{x: number, y: number}} position - Position to restore
|
|
206
|
+
* @returns {Promise<void>} Resolves once restored
|
|
207
|
+
*/
|
|
208
|
+
export async function restoreScrollPosition(adapter, position) {
|
|
209
|
+
if (!position || typeof adapter?.evaluateOnPage !== 'function') {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// `evaluateOnPage` takes an argument *list*, so the position has to be
|
|
213
|
+
// wrapped - passing the object directly made Playwright try to spread it.
|
|
214
|
+
await adapter.evaluateOnPage(
|
|
215
|
+
(pos) => window.scrollTo(pos.x, pos.y),
|
|
216
|
+
[position]
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Deliver a click according to the resolved activation options.
|
|
222
|
+
*
|
|
223
|
+
* @param {Object} options - Configuration options
|
|
224
|
+
* @param {Object} options.page - Browser page object
|
|
225
|
+
* @param {Object} options.adapter - Engine adapter
|
|
226
|
+
* @param {Object} options.locatorOrElement - Element or locator to click
|
|
227
|
+
* @param {Object} options.activationOptions - Result of {@link resolveActivationOptions}
|
|
228
|
+
* @param {Function} [options.log] - Logger instance
|
|
229
|
+
* @returns {Promise<{mode: string, scrollBefore: Object|null, scrollAfter: Object|null, point: Object|null}>} Dispatch detail
|
|
230
|
+
* @throws {ScrollConstraintError} When `scroll: 'none'` cannot be honored
|
|
231
|
+
*/
|
|
232
|
+
export async function dispatchClick(options = {}) {
|
|
233
|
+
const {
|
|
234
|
+
page,
|
|
235
|
+
adapter,
|
|
236
|
+
locatorOrElement,
|
|
237
|
+
activationOptions,
|
|
238
|
+
log = { debug: () => {} },
|
|
239
|
+
} = options;
|
|
240
|
+
|
|
241
|
+
const { activation, scroll, actionability } = activationOptions;
|
|
242
|
+
|
|
243
|
+
if (activation === CLICK_ACTIVATION.DOM) {
|
|
244
|
+
log.debug(
|
|
245
|
+
() =>
|
|
246
|
+
'🖱️ Dispatching DOM activation (HTMLElement.click(); untrusted event, no scrolling)'
|
|
247
|
+
);
|
|
248
|
+
const scrollBefore = await readScrollPosition(adapter);
|
|
249
|
+
await adapter.evaluateOnElement(locatorOrElement, (el) => el.click());
|
|
250
|
+
return {
|
|
251
|
+
mode: CLICK_ACTIVATION.DOM,
|
|
252
|
+
scrollBefore,
|
|
253
|
+
scrollAfter: await readScrollPosition(adapter),
|
|
254
|
+
point: null,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (scroll === CLICK_SCROLL.NONE) {
|
|
259
|
+
return dispatchPointerWithoutScrolling({
|
|
260
|
+
page,
|
|
261
|
+
adapter,
|
|
262
|
+
locatorOrElement,
|
|
263
|
+
log,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const scrollBefore = await readScrollPosition(adapter);
|
|
268
|
+
const engineOptions =
|
|
269
|
+
actionability === CLICK_ACTIONABILITY.FORCE ? { force: true } : {};
|
|
270
|
+
await adapter.click(locatorOrElement, engineOptions);
|
|
271
|
+
|
|
272
|
+
if (scroll === CLICK_SCROLL.PRESERVE) {
|
|
273
|
+
await restoreScrollPosition(adapter, scrollBefore);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
mode: CLICK_ACTIVATION.POINTER,
|
|
278
|
+
scrollBefore,
|
|
279
|
+
scrollAfter: await readScrollPosition(adapter),
|
|
280
|
+
point: null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function dispatchPointerWithoutScrolling(options) {
|
|
285
|
+
const { page, adapter, locatorOrElement, log } = options;
|
|
286
|
+
|
|
287
|
+
const point = await measureClickPoint({ adapter, locatorOrElement });
|
|
288
|
+
|
|
289
|
+
if (!point.inViewport) {
|
|
290
|
+
throw new ScrollConstraintError(
|
|
291
|
+
'scroll: "none" was requested but the element is outside the viewport, ' +
|
|
292
|
+
'so a real pointer click cannot reach it without scrolling. Use ' +
|
|
293
|
+
'scroll: "preserve" to scroll and restore, scroll: "auto" to allow ' +
|
|
294
|
+
'scrolling, or activation: "dom" to dispatch an untrusted click.',
|
|
295
|
+
point
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!point.hitsTarget) {
|
|
300
|
+
throw new ScrollConstraintError(
|
|
301
|
+
'scroll: "none" was requested but another element covers the target at ' +
|
|
302
|
+
'its click point, so a real pointer click would hit the wrong element.',
|
|
303
|
+
point
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
log.debug(
|
|
308
|
+
() =>
|
|
309
|
+
`🖱️ Pointer click at (${Math.round(point.x)}, ${Math.round(point.y)}) without scrolling`
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const scrollBefore = { x: point.scroll.x, y: point.scroll.y };
|
|
313
|
+
await page.mouse.click(point.x, point.y);
|
|
314
|
+
const scrollAfter = await readScrollPosition(adapter);
|
|
315
|
+
|
|
316
|
+
return { mode: CLICK_ACTIVATION.POINTER, scrollBefore, scrollAfter, point };
|
|
317
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truthful result model for click operations.
|
|
3
|
+
*
|
|
4
|
+
* The old model answered every click with two booleans, and both of them were
|
|
5
|
+
* optimistic: a button that did nothing still reported `verified: true`. This
|
|
6
|
+
* model separates three questions that used to be conflated - did we dispatch
|
|
7
|
+
* the click, did the page react, and how did the operation end - and records
|
|
8
|
+
* the evidence behind each answer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
/** How a click operation ended. */
|
|
14
|
+
export const CLICK_STATUS = Object.freeze({
|
|
15
|
+
/** Click dispatched and its effect was confirmed. */
|
|
16
|
+
SUCCEEDED: 'succeeded',
|
|
17
|
+
/** Click could not be dispatched, or dispatch provably failed. */
|
|
18
|
+
FAILED: 'failed',
|
|
19
|
+
/** The operation ran out of its budget. */
|
|
20
|
+
TIMED_OUT: 'timed_out',
|
|
21
|
+
/** Navigation or an explicit stop cut the operation short. */
|
|
22
|
+
INTERRUPTED: 'interrupted',
|
|
23
|
+
/** Click dispatched, but nothing confirmed or denied that it had an effect. */
|
|
24
|
+
UNVERIFIED: 'unverified',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** What the page did in response to the click. */
|
|
28
|
+
export const CLICK_EFFECT = Object.freeze({
|
|
29
|
+
/** Observed evidence that the click did something. */
|
|
30
|
+
CONFIRMED: 'confirmed',
|
|
31
|
+
/** No evidence either way. */
|
|
32
|
+
NOT_OBSERVED: 'not-observed',
|
|
33
|
+
/** Observed evidence that the click did NOT do what was expected. */
|
|
34
|
+
CONTRADICTED: 'contradicted',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build one piece of evidence for a click or readiness decision.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} type - Evidence kind, e.g. 'element-state' or 'navigation'
|
|
41
|
+
* @param {Object} [detail] - Arbitrary structured detail
|
|
42
|
+
* @returns {{type: string, detail: Object}} Evidence entry
|
|
43
|
+
*/
|
|
44
|
+
export function evidence(type, detail = {}) {
|
|
45
|
+
return { type, detail };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a click result, deriving the legacy booleans conservatively.
|
|
50
|
+
*
|
|
51
|
+
* `verified` is deliberately derived from `effect === 'confirmed'` rather than
|
|
52
|
+
* from "nothing went wrong", which is what made the old value meaningless.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} options - Configuration options
|
|
55
|
+
* @param {string} options.status - One of {@link CLICK_STATUS}
|
|
56
|
+
* @param {boolean} [options.dispatched=false] - Whether the click reached the element
|
|
57
|
+
* @param {string} [options.effect] - One of {@link CLICK_EFFECT}
|
|
58
|
+
* @param {Array} [options.evidence] - Evidence entries
|
|
59
|
+
* @param {number} [options.elapsedMs=0] - Duration of the operation
|
|
60
|
+
* @param {string} [options.reason=''] - Human-readable summary
|
|
61
|
+
* @param {boolean} [options.navigated=false] - Whether navigation was attributed to this click
|
|
62
|
+
* @param {string} [options.actionId] - Correlation ID for this click
|
|
63
|
+
* @returns {Object} Click result
|
|
64
|
+
*/
|
|
65
|
+
export function makeClickResult(options = {}) {
|
|
66
|
+
const {
|
|
67
|
+
status,
|
|
68
|
+
dispatched = false,
|
|
69
|
+
effect = CLICK_EFFECT.NOT_OBSERVED,
|
|
70
|
+
evidence: evidenceList = [],
|
|
71
|
+
elapsedMs = 0,
|
|
72
|
+
reason = '',
|
|
73
|
+
navigated = false,
|
|
74
|
+
actionId,
|
|
75
|
+
...rest
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
status,
|
|
80
|
+
dispatched,
|
|
81
|
+
effect,
|
|
82
|
+
evidence: evidenceList,
|
|
83
|
+
elapsedMs,
|
|
84
|
+
reason,
|
|
85
|
+
navigated,
|
|
86
|
+
...(actionId === undefined ? {} : { actionId }),
|
|
87
|
+
|
|
88
|
+
// Legacy booleans, kept so existing callers keep working - but now derived
|
|
89
|
+
// from what was actually observed.
|
|
90
|
+
clicked: dispatched,
|
|
91
|
+
verified: effect === CLICK_EFFECT.CONFIRMED,
|
|
92
|
+
...rest,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Mint a correlation ID so navigation evidence can be tied to one click.
|
|
98
|
+
*
|
|
99
|
+
* The random half comes from the system CSPRNG rather than `Math.random()`.
|
|
100
|
+
* The ID is only a correlation key, but it travels into logs and trace
|
|
101
|
+
* bundles, where a reader cannot tell a correlation key from a token; taking
|
|
102
|
+
* the characters from `randomUUID` costs nothing and settles the question.
|
|
103
|
+
*
|
|
104
|
+
* @returns {string} Opaque action ID
|
|
105
|
+
*/
|
|
106
|
+
export function nextActionId() {
|
|
107
|
+
return `click-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
108
|
+
}
|