@oxvo/ai-live-assist 7.3.4 → 7.4.23
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/cjs/AiLiveAssist.d.ts +588 -6
- package/cjs/AiLiveAssist.js +14892 -1240
- package/cjs/client.d.ts +71 -10
- package/cjs/client.js +316 -58
- package/cjs/context.d.ts +15 -5
- package/cjs/context.js +568 -73
- package/cjs/control.d.ts +72 -7
- package/cjs/control.js +594 -71
- package/cjs/dialogFocusScope.d.ts +37 -0
- package/cjs/dialogFocusScope.js +227 -0
- package/cjs/experienceState.js +1 -1
- package/cjs/index.d.ts +1 -1
- package/cjs/index.js +2 -2
- package/cjs/media.d.ts +14 -3
- package/cjs/media.js +128 -19
- package/cjs/messages.js +26 -3
- package/cjs/messengerLoader.d.ts +3 -0
- package/cjs/messengerLoader.js +156 -0
- package/cjs/pageAssistancePreference.d.ts +78 -0
- package/cjs/pageAssistancePreference.js +488 -0
- package/cjs/preview.d.ts +33 -0
- package/cjs/preview.js +567 -0
- package/cjs/tabLock.d.ts +54 -3
- package/cjs/tabLock.js +752 -72
- package/cjs/types.d.ts +152 -4
- package/cjs/ui.d.ts +200 -10
- package/cjs/ui.js +6946 -523
- package/cjs/version.d.ts +1 -1
- package/cjs/version.js +1 -1
- package/cjs/voicePresenceUi.d.ts +48 -9
- package/cjs/voicePresenceUi.js +863 -125
- package/lib/AiLiveAssist.d.ts +588 -6
- package/lib/AiLiveAssist.js +14897 -1248
- package/lib/client.d.ts +71 -10
- package/lib/client.js +313 -57
- package/lib/context.d.ts +15 -5
- package/lib/context.js +566 -72
- package/lib/control.d.ts +72 -7
- package/lib/control.js +594 -71
- package/lib/dialogFocusScope.d.ts +37 -0
- package/lib/dialogFocusScope.js +223 -0
- package/lib/experienceState.js +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/media.d.ts +14 -3
- package/lib/media.js +128 -19
- package/lib/messages.js +26 -3
- package/lib/messengerLoader.d.ts +3 -0
- package/lib/messengerLoader.js +151 -0
- package/lib/pageAssistancePreference.d.ts +78 -0
- package/lib/pageAssistancePreference.js +482 -0
- package/lib/preview.d.ts +33 -0
- package/lib/preview.js +561 -0
- package/lib/tabLock.d.ts +54 -3
- package/lib/tabLock.js +752 -72
- package/lib/types.d.ts +152 -4
- package/lib/ui.d.ts +200 -10
- package/lib/ui.js +6947 -524
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/voicePresenceUi.d.ts +48 -9
- package/lib/voicePresenceUi.js +863 -125
- package/package.json +8 -3
- package/cjs/recordingPolicy.d.ts +0 -2
- package/cjs/recordingPolicy.js +0 -12
- package/lib/recordingPolicy.d.ts +0 -2
- package/lib/recordingPolicy.js +0 -8
package/cjs/context.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PageContextCollector = void 0;
|
|
3
|
+
exports.PageContextCollector = exports.actionTargetFingerprint = void 0;
|
|
4
4
|
const visualContext_js_1 = require("./visualContext.js");
|
|
5
5
|
const MAX_TARGETS = 256;
|
|
6
6
|
const MAX_TEXT_ITEMS = 256;
|
|
@@ -29,12 +29,64 @@ const SENSITIVE_PATTERNS = [
|
|
|
29
29
|
['authentication_secret', /auth.*secret|private.?key|access.?token/i],
|
|
30
30
|
['api_secret', /api.?key|client.?secret|secret.?key/i],
|
|
31
31
|
];
|
|
32
|
+
const EXPLICIT_SENSITIVITIES = new Set([
|
|
33
|
+
'private',
|
|
34
|
+
'password',
|
|
35
|
+
'otp',
|
|
36
|
+
'payment',
|
|
37
|
+
'banking',
|
|
38
|
+
'recovery',
|
|
39
|
+
'authentication_secret',
|
|
40
|
+
'api_secret',
|
|
41
|
+
]);
|
|
32
42
|
const id = (prefix) => `${prefix}_${crypto.randomUUID()}`;
|
|
33
43
|
const normalize = (value, maximum) => value
|
|
34
44
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
|
35
45
|
.replace(/\s+/g, ' ')
|
|
36
46
|
.trim()
|
|
37
47
|
.slice(0, maximum);
|
|
48
|
+
const canonicalJson = (value) => {
|
|
49
|
+
const sort = (item) => {
|
|
50
|
+
if (Array.isArray(item))
|
|
51
|
+
return item.map(sort);
|
|
52
|
+
if (item !== null && typeof item === 'object') {
|
|
53
|
+
return Object.fromEntries(Object.entries(item)
|
|
54
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
55
|
+
.map(([key, nested]) => [key, sort(nested)]));
|
|
56
|
+
}
|
|
57
|
+
return item;
|
|
58
|
+
};
|
|
59
|
+
return JSON.stringify(sort(value));
|
|
60
|
+
};
|
|
61
|
+
const actionTargetFingerprint = async (target, subtle = globalThis.crypto?.subtle) => {
|
|
62
|
+
if (!subtle)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const canonical = canonicalJson({
|
|
66
|
+
targetId: target.targetId,
|
|
67
|
+
role: target.role,
|
|
68
|
+
name: target.name,
|
|
69
|
+
inputType: target.inputType ?? null,
|
|
70
|
+
autocomplete: target.autocomplete ?? null,
|
|
71
|
+
semanticRegion: target.semanticRegion ?? null,
|
|
72
|
+
destinationPath: target.destinationPath ?? null,
|
|
73
|
+
sensitivity: target.sensitivity,
|
|
74
|
+
protectedRegion: target.protectedRegion ?? null,
|
|
75
|
+
frameOrigin: target.frameOrigin ?? null,
|
|
76
|
+
consequence: target.consequence ?? null,
|
|
77
|
+
regionAssessment: target.regionAssessment ?? null,
|
|
78
|
+
options: target.options ?? null,
|
|
79
|
+
});
|
|
80
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(canonical)));
|
|
81
|
+
if (digest.byteLength !== 32)
|
|
82
|
+
return null;
|
|
83
|
+
return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
exports.actionTargetFingerprint = actionTargetFingerprint;
|
|
38
90
|
const isHTMLElement = (value) => value instanceof HTMLElement;
|
|
39
91
|
const isRendered = (element) => {
|
|
40
92
|
if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR)) {
|
|
@@ -60,11 +112,22 @@ const isVisible = (element) => {
|
|
|
60
112
|
rect.top <= window.innerHeight &&
|
|
61
113
|
rect.left <= window.innerWidth);
|
|
62
114
|
};
|
|
63
|
-
const isCovered = (element) => {
|
|
115
|
+
const isCovered = (element, trustedGuideRoot = null) => {
|
|
64
116
|
const rect = element.getBoundingClientRect();
|
|
65
117
|
const x = Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2));
|
|
66
118
|
const y = Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2));
|
|
67
|
-
|
|
119
|
+
let top;
|
|
120
|
+
if (trustedGuideRoot?.isConnected &&
|
|
121
|
+
typeof document.elementsFromPoint === 'function') {
|
|
122
|
+
top =
|
|
123
|
+
document
|
|
124
|
+
.elementsFromPoint(x, y)
|
|
125
|
+
.find((candidate) => candidate !== trustedGuideRoot) ??
|
|
126
|
+
document.elementFromPoint(x, y);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
top = document.elementFromPoint(x, y);
|
|
130
|
+
}
|
|
68
131
|
return Boolean(top && top !== element && !element.contains(top) && !top.contains(element));
|
|
69
132
|
};
|
|
70
133
|
const safePageUrl = () => `${window.location.origin}${window.location.pathname}`;
|
|
@@ -97,17 +160,22 @@ const sensitivityFor = (element, name) => {
|
|
|
97
160
|
return 'otp';
|
|
98
161
|
if (/^cc-/.test(autocomplete))
|
|
99
162
|
return 'payment';
|
|
163
|
+
const explicitSensitivity = normalize(element.closest('[data-oxvo-sensitivity]')?.getAttribute('data-oxvo-sensitivity') ?? '', 64);
|
|
164
|
+
if (EXPLICIT_SENSITIVITIES.has(explicitSensitivity))
|
|
165
|
+
return explicitSensitivity;
|
|
100
166
|
const descriptors = [
|
|
101
167
|
name,
|
|
102
168
|
element.getAttribute('name') ?? '',
|
|
103
169
|
element.id,
|
|
104
170
|
element.getAttribute('autocomplete') ?? '',
|
|
105
|
-
element.getAttribute('data-oxvo-sensitivity') ?? '',
|
|
171
|
+
element.closest('[data-oxvo-sensitivity]')?.getAttribute('data-oxvo-sensitivity') ?? '',
|
|
106
172
|
].join(' ');
|
|
107
173
|
for (const [sensitivity, pattern] of SENSITIVE_PATTERNS) {
|
|
108
174
|
if (pattern.test(descriptors))
|
|
109
175
|
return sensitivity;
|
|
110
176
|
}
|
|
177
|
+
if (element.closest('[data-oxvo-sensitivity]'))
|
|
178
|
+
return 'private';
|
|
111
179
|
if (element.closest('[data-oxvo-private]'))
|
|
112
180
|
return 'private';
|
|
113
181
|
return 'none';
|
|
@@ -141,11 +209,6 @@ const roleFor = (element) => {
|
|
|
141
209
|
return tag.slice(0, 64);
|
|
142
210
|
};
|
|
143
211
|
const nameFor = (element) => {
|
|
144
|
-
const labelledBy = element.getAttribute('aria-labelledby');
|
|
145
|
-
const labelledText = labelledBy
|
|
146
|
-
?.split(/\s+/)
|
|
147
|
-
.map((value) => document.getElementById(value)?.textContent ?? '')
|
|
148
|
-
.join(' ');
|
|
149
212
|
const input = element instanceof HTMLInputElement ? element : null;
|
|
150
213
|
const formControl = element instanceof HTMLInputElement ||
|
|
151
214
|
element instanceof HTMLSelectElement ||
|
|
@@ -154,49 +217,208 @@ const nameFor = (element) => {
|
|
|
154
217
|
: null;
|
|
155
218
|
const associatedLabel = formControl
|
|
156
219
|
? Array.from(formControl.labels ?? [])
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
const text = [];
|
|
160
|
-
let node = walker.nextNode();
|
|
161
|
-
while (node) {
|
|
162
|
-
if (!node.parentElement?.closest('input,select,textarea,option,button')) {
|
|
163
|
-
text.push(node.textContent ?? '');
|
|
164
|
-
}
|
|
165
|
-
node = walker.nextNode();
|
|
166
|
-
}
|
|
167
|
-
return text;
|
|
168
|
-
})
|
|
220
|
+
.map((label) => safeAccessibleNameText(label, 300))
|
|
221
|
+
.filter(Boolean)
|
|
169
222
|
.join(' ')
|
|
170
223
|
: '';
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
element
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
const safeAttribute = (attribute, maximum) => {
|
|
225
|
+
const value = normalize(element.getAttribute(attribute) ?? '', maximum);
|
|
226
|
+
return value && sensitivityFor(element, value) === 'none' ? value : '';
|
|
227
|
+
};
|
|
228
|
+
const semanticName = safeAccessibleNameText(element, 300);
|
|
229
|
+
const attributeName = safeAttribute('alt', 300) ||
|
|
230
|
+
safeAttribute('title', 300) ||
|
|
231
|
+
safeAttribute('placeholder', 300);
|
|
176
232
|
if (element instanceof HTMLFormElement) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
233
|
+
const explicitSemanticName = element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')
|
|
234
|
+
? semanticName
|
|
235
|
+
: '';
|
|
236
|
+
return normalize(explicitSemanticName ||
|
|
237
|
+
attributeName ||
|
|
238
|
+
safeAttribute('data-oxvo-ai-region', 120) ||
|
|
239
|
+
safeAttribute('name', 120) ||
|
|
180
240
|
'Form', 120);
|
|
181
241
|
}
|
|
182
|
-
|
|
183
|
-
(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
242
|
+
const buttonValue = input && ['button', 'submit', 'reset'].includes(input.type)
|
|
243
|
+
? normalize(input.value, 300)
|
|
244
|
+
: '';
|
|
245
|
+
return normalize(semanticName ||
|
|
246
|
+
associatedLabel ||
|
|
247
|
+
attributeName ||
|
|
248
|
+
(buttonValue && sensitivityFor(element, buttonValue) === 'none' ? buttonValue : ''), 300);
|
|
249
|
+
};
|
|
250
|
+
const safeDescendantText = (element, maximum, renderedOnly = false) => {
|
|
251
|
+
const parts = [];
|
|
252
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
253
|
+
let node = walker.nextNode();
|
|
254
|
+
while (node) {
|
|
255
|
+
const parent = node.parentElement;
|
|
256
|
+
const text = normalize(node.textContent ?? '', maximum);
|
|
257
|
+
if (parent &&
|
|
258
|
+
text &&
|
|
259
|
+
!parent.closest(PRIVATE_SELECTOR) &&
|
|
260
|
+
!parent.closest('script,style,noscript,template,svg,input,select,textarea,option') &&
|
|
261
|
+
(renderedOnly ? isRendered(parent) : isVisible(parent)) &&
|
|
262
|
+
sensitivityFor(parent, text) === 'none') {
|
|
263
|
+
parts.push(text);
|
|
264
|
+
}
|
|
265
|
+
node = walker.nextNode();
|
|
266
|
+
}
|
|
267
|
+
return normalize(parts.join(' '), maximum);
|
|
268
|
+
};
|
|
269
|
+
const safeSemanticText = (element, maximum) => {
|
|
270
|
+
if (!isVisible(element) || element.closest(PRIVATE_SELECTOR))
|
|
271
|
+
return '';
|
|
272
|
+
const explicit = normalize(element.getAttribute('aria-label') ?? '', maximum);
|
|
273
|
+
if (explicit) {
|
|
274
|
+
return sensitivityFor(element, explicit) === 'none' ? explicit : '';
|
|
275
|
+
}
|
|
276
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
277
|
+
const labelledParts = labelledBy
|
|
278
|
+
? labelledBy
|
|
279
|
+
.split(/\s+/)
|
|
280
|
+
.map((idValue) => document.getElementById(idValue))
|
|
281
|
+
.filter((label) => Boolean(label))
|
|
282
|
+
.flatMap((label) => {
|
|
283
|
+
if (!isVisible(label) || label.closest(PRIVATE_SELECTOR))
|
|
284
|
+
return [];
|
|
285
|
+
const text = safeDescendantText(label, maximum);
|
|
286
|
+
return text && sensitivityFor(label, text) === 'none' ? [text] : [];
|
|
287
|
+
})
|
|
288
|
+
: [];
|
|
289
|
+
if (labelledParts.length > 0) {
|
|
290
|
+
const text = normalize(labelledParts.join(' '), maximum);
|
|
291
|
+
return sensitivityFor(element, text) === 'none' ? text : '';
|
|
292
|
+
}
|
|
293
|
+
const text = safeDescendantText(element, maximum);
|
|
294
|
+
return text && sensitivityFor(element, text) === 'none' ? text : '';
|
|
295
|
+
};
|
|
296
|
+
const safeAccessibleNameText = (element, maximum) => {
|
|
297
|
+
if (!isRendered(element) || element.closest(PRIVATE_SELECTOR))
|
|
298
|
+
return '';
|
|
299
|
+
const explicit = normalize(element.getAttribute('aria-label') ?? '', maximum);
|
|
300
|
+
if (explicit) {
|
|
301
|
+
return sensitivityFor(element, explicit) === 'none' ? explicit : '';
|
|
302
|
+
}
|
|
303
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
304
|
+
const labelledParts = labelledBy
|
|
305
|
+
? labelledBy
|
|
306
|
+
.split(/\s+/)
|
|
307
|
+
.map((idValue) => document.getElementById(idValue))
|
|
308
|
+
.filter((label) => Boolean(label))
|
|
309
|
+
.flatMap((label) => {
|
|
310
|
+
if (!isRendered(label) || label.closest(PRIVATE_SELECTOR))
|
|
311
|
+
return [];
|
|
312
|
+
const text = safeDescendantText(label, maximum, true);
|
|
313
|
+
return text && sensitivityFor(label, text) === 'none' ? [text] : [];
|
|
314
|
+
})
|
|
315
|
+
: [];
|
|
316
|
+
if (labelledParts.length > 0) {
|
|
317
|
+
const text = normalize(labelledParts.join(' '), maximum);
|
|
318
|
+
return sensitivityFor(element, text) === 'none' ? text : '';
|
|
319
|
+
}
|
|
320
|
+
const text = safeDescendantText(element, maximum, true);
|
|
321
|
+
return text && sensitivityFor(element, text) === 'none' ? text : '';
|
|
322
|
+
};
|
|
323
|
+
const semanticSourceSensitivity = (source) => {
|
|
324
|
+
if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
|
|
325
|
+
return 'private';
|
|
326
|
+
}
|
|
327
|
+
const markedSources = [
|
|
328
|
+
...(source.matches('[data-oxvo-sensitivity]') ? [source] : []),
|
|
329
|
+
...Array.from(source.querySelectorAll('[data-oxvo-sensitivity]')),
|
|
330
|
+
];
|
|
331
|
+
for (const marked of markedSources) {
|
|
332
|
+
const marker = normalize(marked.getAttribute('data-oxvo-sensitivity') ?? '', 64);
|
|
333
|
+
if (EXPLICIT_SENSITIVITIES.has(marker))
|
|
334
|
+
return marker;
|
|
335
|
+
const descriptor = normalize(`${marker} ${marked.textContent ?? ''}`, 500);
|
|
336
|
+
for (const [sensitivity, pattern] of SENSITIVE_PATTERNS) {
|
|
337
|
+
if (pattern.test(descriptor))
|
|
338
|
+
return sensitivity;
|
|
339
|
+
}
|
|
340
|
+
return 'private';
|
|
341
|
+
}
|
|
342
|
+
const text = normalize(`${source.getAttribute('label') ?? ''} ${source.textContent ?? ''}`, 500);
|
|
343
|
+
return sensitivityFor(source, text);
|
|
344
|
+
};
|
|
345
|
+
const accessibleNameSourceSensitivity = (element) => {
|
|
346
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
347
|
+
const referencedLabels = labelledBy
|
|
348
|
+
? labelledBy
|
|
349
|
+
.split(/\s+/)
|
|
350
|
+
.map((idValue) => document.getElementById(idValue))
|
|
351
|
+
.filter((label) => Boolean(label))
|
|
352
|
+
: [];
|
|
353
|
+
const formControl = element instanceof HTMLInputElement ||
|
|
354
|
+
element instanceof HTMLSelectElement ||
|
|
355
|
+
element instanceof HTMLTextAreaElement
|
|
356
|
+
? element
|
|
357
|
+
: null;
|
|
358
|
+
const sources = [
|
|
359
|
+
...referencedLabels,
|
|
360
|
+
...Array.from(formControl?.labels ?? []),
|
|
361
|
+
];
|
|
362
|
+
for (const source of sources) {
|
|
363
|
+
const sensitivity = semanticSourceSensitivity(source);
|
|
364
|
+
if (sensitivity !== 'none')
|
|
365
|
+
return sensitivity;
|
|
366
|
+
}
|
|
367
|
+
return 'none';
|
|
368
|
+
};
|
|
369
|
+
const safeOptionLabel = (option) => {
|
|
370
|
+
const label = normalize(option.label || option.textContent || '', 300);
|
|
371
|
+
if (!label || semanticSourceSensitivity(option) !== 'none')
|
|
372
|
+
return '';
|
|
373
|
+
const group = option.closest('optgroup');
|
|
374
|
+
if (group && semanticSourceSensitivity(group) !== 'none')
|
|
375
|
+
return '';
|
|
376
|
+
return sensitivityFor(option, label) === 'none' ? label : '';
|
|
187
377
|
};
|
|
188
378
|
const isActionTarget = (element) => element.matches('a[href],button,input,select,textarea,summary,form,[contenteditable="true"],[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="combobox"],[role="textbox"],[tabindex]');
|
|
379
|
+
const READONLY_INPUT_TYPES = new Set([
|
|
380
|
+
'date',
|
|
381
|
+
'datetime-local',
|
|
382
|
+
'email',
|
|
383
|
+
'month',
|
|
384
|
+
'number',
|
|
385
|
+
'password',
|
|
386
|
+
'search',
|
|
387
|
+
'tel',
|
|
388
|
+
'text',
|
|
389
|
+
'time',
|
|
390
|
+
'url',
|
|
391
|
+
'week',
|
|
392
|
+
]);
|
|
393
|
+
const isReadOnlyTextControl = (element) => (element instanceof HTMLInputElement &&
|
|
394
|
+
READONLY_INPUT_TYPES.has(element.type) &&
|
|
395
|
+
element.readOnly) ||
|
|
396
|
+
(element instanceof HTMLTextAreaElement && element.readOnly);
|
|
189
397
|
const enabled = (element) => {
|
|
190
|
-
if (element.getAttribute('aria-disabled') === 'true'
|
|
398
|
+
if (element.getAttribute('aria-disabled') === 'true' ||
|
|
399
|
+
element.getAttribute('aria-readonly') === 'true' ||
|
|
400
|
+
isReadOnlyTextControl(element)) {
|
|
191
401
|
return false;
|
|
402
|
+
}
|
|
192
403
|
if (element instanceof HTMLButtonElement ||
|
|
193
404
|
element instanceof HTMLInputElement ||
|
|
194
405
|
element instanceof HTMLSelectElement ||
|
|
195
406
|
element instanceof HTMLTextAreaElement) {
|
|
196
|
-
return !element.disabled;
|
|
407
|
+
return !element.matches(':disabled');
|
|
197
408
|
}
|
|
198
409
|
return true;
|
|
199
410
|
};
|
|
411
|
+
const optionDisabled = (option) => option.disabled ||
|
|
412
|
+
option.closest('optgroup')?.disabled === true ||
|
|
413
|
+
option.matches(':disabled');
|
|
414
|
+
const literalBooleanAttribute = (element, attribute) => {
|
|
415
|
+
const value = element.getAttribute(attribute);
|
|
416
|
+
if (value === 'true')
|
|
417
|
+
return true;
|
|
418
|
+
if (value === 'false')
|
|
419
|
+
return false;
|
|
420
|
+
return undefined;
|
|
421
|
+
};
|
|
200
422
|
const consequenceFor = (element, role, name) => {
|
|
201
423
|
const form = element instanceof HTMLFormElement ? element : element.closest('form');
|
|
202
424
|
const explicit = element
|
|
@@ -247,10 +469,12 @@ const consequenceFor = (element, role, name) => {
|
|
|
247
469
|
return role === 'button' || role === 'checkbox' || role === 'radio' ? 'state_change' : 'none';
|
|
248
470
|
};
|
|
249
471
|
class PageContextCollector {
|
|
250
|
-
constructor(callbacks, selectorRegionRules = [], policyVersion = 1) {
|
|
472
|
+
constructor(callbacks, selectorRegionRules = [], policyVersion = 1, trustedGuideRoot = null, statefulButtonActionProof = false) {
|
|
251
473
|
this.callbacks = callbacks;
|
|
252
474
|
this.selectorRegionRules = selectorRegionRules;
|
|
253
475
|
this.policyVersion = policyVersion;
|
|
476
|
+
this.trustedGuideRoot = trustedGuideRoot;
|
|
477
|
+
this.statefulButtonActionProof = statefulButtonActionProof;
|
|
254
478
|
this.pageId = id('page');
|
|
255
479
|
this.revision = 0;
|
|
256
480
|
this.running = false;
|
|
@@ -267,10 +491,12 @@ class PageContextCollector {
|
|
|
267
491
|
this.pendingNavigation = null;
|
|
268
492
|
this.pendingCapture = false;
|
|
269
493
|
this.deferredNavigation = null;
|
|
494
|
+
this.navigationSettlementGeneration = 0;
|
|
495
|
+
this.navigationSettlement = null;
|
|
270
496
|
this.recentSelection = null;
|
|
271
497
|
this.latestSemanticSignature = null;
|
|
272
498
|
this.scheduleCapture = () => {
|
|
273
|
-
if (!this.running)
|
|
499
|
+
if (!this.running || this.navigationSettlement)
|
|
274
500
|
return;
|
|
275
501
|
if (this.executingActionId) {
|
|
276
502
|
this.pendingCapture = true;
|
|
@@ -287,7 +513,7 @@ class PageContextCollector {
|
|
|
287
513
|
}, CAPTURE_SETTLE_MS);
|
|
288
514
|
};
|
|
289
515
|
this.scheduleMutationCapture = () => {
|
|
290
|
-
if (!this.running)
|
|
516
|
+
if (!this.running || this.navigationSettlement)
|
|
291
517
|
return;
|
|
292
518
|
if (this.executingActionId) {
|
|
293
519
|
this.pendingCapture = true;
|
|
@@ -328,6 +554,14 @@ class PageContextCollector {
|
|
|
328
554
|
this.handleNavigation = () => {
|
|
329
555
|
if (!this.running)
|
|
330
556
|
return;
|
|
557
|
+
const currentUrl = safePageUrl();
|
|
558
|
+
if (this.executingActionId &&
|
|
559
|
+
this.pendingNavigation?.causalActionId === this.executingActionId &&
|
|
560
|
+
this.pendingNavigation.url === currentUrl) {
|
|
561
|
+
this.pendingCapture = true;
|
|
562
|
+
this.reportCausalNavigationObservation();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
331
565
|
this.pageId = id('page');
|
|
332
566
|
this.revision += 1;
|
|
333
567
|
this.targets.clear();
|
|
@@ -336,12 +570,27 @@ class PageContextCollector {
|
|
|
336
570
|
this.pendingNavigation = {
|
|
337
571
|
pageId: this.pageId,
|
|
338
572
|
revision: this.revision,
|
|
339
|
-
url:
|
|
573
|
+
url: currentUrl,
|
|
574
|
+
causalActionId: this.executingActionId,
|
|
575
|
+
causalObservationReported: false,
|
|
340
576
|
};
|
|
341
577
|
this.pendingCapture = true;
|
|
578
|
+
this.reportCausalNavigationObservation();
|
|
342
579
|
return;
|
|
343
580
|
}
|
|
344
|
-
|
|
581
|
+
const settlement = this.beginNavigationSettlement(this.pageId, this.revision, currentUrl);
|
|
582
|
+
void Promise.resolve()
|
|
583
|
+
.then(() => this.callbacks.onNavigation(settlement.pageId, settlement.revision, settlement.url))
|
|
584
|
+
.then((navigationAcknowledged) => {
|
|
585
|
+
if (navigationAcknowledged === false ||
|
|
586
|
+
!this.running ||
|
|
587
|
+
this.navigationSettlement !== settlement) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
this.navigationSettlement = null;
|
|
591
|
+
return this.captureAndPublish();
|
|
592
|
+
})
|
|
593
|
+
.catch(() => undefined);
|
|
345
594
|
};
|
|
346
595
|
}
|
|
347
596
|
get currentRevision() {
|
|
@@ -388,6 +637,7 @@ class PageContextCollector {
|
|
|
388
637
|
'aria-label',
|
|
389
638
|
'aria-disabled',
|
|
390
639
|
'aria-expanded',
|
|
640
|
+
'aria-pressed',
|
|
391
641
|
'aria-live',
|
|
392
642
|
'class',
|
|
393
643
|
'disabled',
|
|
@@ -442,14 +692,19 @@ class PageContextCollector {
|
|
|
442
692
|
this.pendingNavigation = null;
|
|
443
693
|
this.pendingCapture = false;
|
|
444
694
|
this.deferredNavigation = null;
|
|
695
|
+
this.navigationSettlementGeneration += 1;
|
|
696
|
+
this.navigationSettlement = null;
|
|
445
697
|
this.recentSelection = null;
|
|
446
698
|
this.latestSemanticSignature = null;
|
|
447
699
|
this.callbacks.onHighlight(null, 'focus');
|
|
448
700
|
}
|
|
449
701
|
async captureAndPublish() {
|
|
702
|
+
if (this.navigationSettlement)
|
|
703
|
+
return null;
|
|
450
704
|
const snapshot = this.capture();
|
|
451
|
-
if (this.running)
|
|
705
|
+
if (this.running) {
|
|
452
706
|
await this.callbacks.onSnapshot(snapshot);
|
|
707
|
+
}
|
|
453
708
|
return snapshot;
|
|
454
709
|
}
|
|
455
710
|
capture() {
|
|
@@ -468,8 +723,17 @@ class PageContextCollector {
|
|
|
468
723
|
}
|
|
469
724
|
if (!isActionTarget(element) || !isRendered(element))
|
|
470
725
|
continue;
|
|
726
|
+
const role = roleFor(element);
|
|
727
|
+
const hasStatefulButtonAttribute = role === 'button' &&
|
|
728
|
+
(element.hasAttribute('aria-expanded') || element.hasAttribute('aria-pressed'));
|
|
729
|
+
const hasUnsupportedMixedPressedState = role === 'button' &&
|
|
730
|
+
element.getAttribute('aria-pressed')?.trim().toLowerCase() === 'mixed';
|
|
731
|
+
if (hasUnsupportedMixedPressedState ||
|
|
732
|
+
(!this.statefulButtonActionProof && hasStatefulButtonAttribute)) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
471
735
|
const name = nameFor(element);
|
|
472
|
-
if (!name && !['form', 'textbox'].includes(
|
|
736
|
+
if (!name && !['form', 'textbox'].includes(role))
|
|
473
737
|
continue;
|
|
474
738
|
const targetId = this.targetIds.get(element) ?? id('target');
|
|
475
739
|
this.targetIds.set(element, targetId);
|
|
@@ -478,15 +742,18 @@ class PageContextCollector {
|
|
|
478
742
|
if (element instanceof HTMLSelectElement) {
|
|
479
743
|
options = Array.from(element.options)
|
|
480
744
|
.slice(0, MAX_OPTIONS)
|
|
481
|
-
.
|
|
745
|
+
.flatMap((option) => {
|
|
746
|
+
const label = safeOptionLabel(option);
|
|
747
|
+
if (!label)
|
|
748
|
+
return [];
|
|
482
749
|
const optionId = this.optionIds.get(option) ?? id('option');
|
|
483
750
|
this.optionIds.set(option, optionId);
|
|
484
751
|
optionMap.set(optionId, option);
|
|
485
|
-
return {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
752
|
+
return [{
|
|
753
|
+
id: optionId,
|
|
754
|
+
label,
|
|
755
|
+
disabled: optionDisabled(option),
|
|
756
|
+
}];
|
|
490
757
|
});
|
|
491
758
|
}
|
|
492
759
|
const semanticRegion = normalize(element.closest('[data-oxvo-ai-region]')?.getAttribute('data-oxvo-ai-region') ?? '', 128);
|
|
@@ -506,10 +773,14 @@ class PageContextCollector {
|
|
|
506
773
|
// Invalid tenant selectors remain unevaluated so server policy fails closed.
|
|
507
774
|
}
|
|
508
775
|
}
|
|
509
|
-
const
|
|
510
|
-
const sensitivity =
|
|
776
|
+
const directSensitivity = sensitivityFor(element, name);
|
|
777
|
+
const sensitivity = directSensitivity !== 'none'
|
|
778
|
+
? directSensitivity
|
|
779
|
+
: accessibleNameSourceSensitivity(element);
|
|
511
780
|
const visible = isVisible(element);
|
|
512
781
|
const destinationPath = safeDestinationPath(element);
|
|
782
|
+
const expanded = literalBooleanAttribute(element, 'aria-expanded');
|
|
783
|
+
const pressed = literalBooleanAttribute(element, 'aria-pressed');
|
|
513
784
|
const target = {
|
|
514
785
|
targetId,
|
|
515
786
|
revision,
|
|
@@ -535,10 +806,12 @@ class PageContextCollector {
|
|
|
535
806
|
state: {
|
|
536
807
|
visible,
|
|
537
808
|
enabled: enabled(element),
|
|
538
|
-
covered: visible ? isCovered(element) : false,
|
|
809
|
+
covered: visible ? isCovered(element, this.trustedGuideRoot) : false,
|
|
539
810
|
...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
|
|
540
811
|
? { checked: element.checked }
|
|
541
812
|
: {}),
|
|
813
|
+
...(this.statefulButtonActionProof && expanded !== undefined ? { expanded } : {}),
|
|
814
|
+
...(this.statefulButtonActionProof && pressed !== undefined ? { pressed } : {}),
|
|
542
815
|
},
|
|
543
816
|
...(options ? { options } : {}),
|
|
544
817
|
};
|
|
@@ -571,11 +844,12 @@ class PageContextCollector {
|
|
|
571
844
|
const landmarks = Array.from(document.querySelectorAll('main,nav,header,footer,aside,[role="main"],[role="navigation"]'))
|
|
572
845
|
.filter(isVisible)
|
|
573
846
|
.slice(0, 32)
|
|
574
|
-
.map((element) => ({ role: roleFor(element), label:
|
|
847
|
+
.map((element) => ({ role: roleFor(element), label: safeSemanticText(element, 300) }))
|
|
848
|
+
.filter(({ label }) => Boolean(label));
|
|
575
849
|
const alerts = Array.from(document.querySelectorAll('[role="alert"],[aria-live]'))
|
|
576
850
|
.filter(isVisible)
|
|
577
851
|
.slice(0, 16)
|
|
578
|
-
.map((element) =>
|
|
852
|
+
.map((element) => safeSemanticText(element, 500))
|
|
579
853
|
.filter(Boolean);
|
|
580
854
|
const activeSelection = this.readSafeSelection();
|
|
581
855
|
if (activeSelection)
|
|
@@ -612,6 +886,8 @@ class PageContextCollector {
|
|
|
612
886
|
return snapshot;
|
|
613
887
|
}
|
|
614
888
|
captureVisualContext() {
|
|
889
|
+
if (this.navigationSettlement)
|
|
890
|
+
return null;
|
|
615
891
|
if (this.revision < 1 || this.targets.size === 0)
|
|
616
892
|
return null;
|
|
617
893
|
const viewportWidth = Math.max(1, Math.round(window.innerWidth));
|
|
@@ -650,14 +926,81 @@ class PageContextCollector {
|
|
|
650
926
|
regions,
|
|
651
927
|
});
|
|
652
928
|
}
|
|
653
|
-
async execute(grant) {
|
|
654
|
-
const handle = this.targets.get(grant.targetId);
|
|
929
|
+
async execute(grant, authorityIsCurrent = () => true) {
|
|
655
930
|
const base = {
|
|
656
931
|
grant: grant.grant,
|
|
657
932
|
actionId: grant.actionId,
|
|
658
933
|
};
|
|
659
|
-
|
|
660
|
-
|
|
934
|
+
const authorityStale = () => ({
|
|
935
|
+
...base,
|
|
936
|
+
status: 'stale',
|
|
937
|
+
observed: {
|
|
938
|
+
url: safePageUrl(),
|
|
939
|
+
revision: this.revision,
|
|
940
|
+
targetState: 'unchanged',
|
|
941
|
+
safeSummary: 'Page assistance permission changed before execution.',
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
const expiresAt = Date.parse(grant.expiresAt);
|
|
945
|
+
const approvalExpired = () => ({
|
|
946
|
+
...base,
|
|
947
|
+
status: 'stale',
|
|
948
|
+
observed: {
|
|
949
|
+
url: safePageUrl(),
|
|
950
|
+
revision: this.revision,
|
|
951
|
+
targetState: 'unchanged',
|
|
952
|
+
safeSummary: 'The approved page action expired before execution.',
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
const executionStale = () => {
|
|
956
|
+
if (!authorityIsCurrent())
|
|
957
|
+
return authorityStale();
|
|
958
|
+
return expiresAt <= Date.now() ? approvalExpired() : null;
|
|
959
|
+
};
|
|
960
|
+
const initialStale = executionStale();
|
|
961
|
+
if (initialStale)
|
|
962
|
+
return initialStale;
|
|
963
|
+
this.captureSnapshot(false);
|
|
964
|
+
let handle = this.targets.get(grant.targetId);
|
|
965
|
+
const revisionMatchesOrStableNavigation = grant.domRevision === this.revision ||
|
|
966
|
+
(grant.domRevision < this.revision &&
|
|
967
|
+
grant.action === 'click' &&
|
|
968
|
+
handle?.element instanceof HTMLAnchorElement &&
|
|
969
|
+
handle.target.consequence === 'navigation' &&
|
|
970
|
+
typeof handle.target.destinationPath === 'string');
|
|
971
|
+
if (expiresAt <= Date.now() ||
|
|
972
|
+
grant.pageId !== this.pageId ||
|
|
973
|
+
grant.pageUrl !== safePageUrl() ||
|
|
974
|
+
grant.policyVersion !== this.policyVersion ||
|
|
975
|
+
!revisionMatchesOrStableNavigation ||
|
|
976
|
+
!handle ||
|
|
977
|
+
handle.revision !== this.revision ||
|
|
978
|
+
!handle.element.isConnected) {
|
|
979
|
+
return {
|
|
980
|
+
...base,
|
|
981
|
+
status: 'stale',
|
|
982
|
+
observed: {
|
|
983
|
+
url: safePageUrl(),
|
|
984
|
+
revision: this.revision,
|
|
985
|
+
targetState: 'detached',
|
|
986
|
+
safeSummary: 'The requested target changed before execution.',
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const fingerprintedHandle = handle;
|
|
991
|
+
const fingerprint = await (0, exports.actionTargetFingerprint)(handle.target);
|
|
992
|
+
const postFingerprintStale = executionStale();
|
|
993
|
+
if (postFingerprintStale)
|
|
994
|
+
return postFingerprintStale;
|
|
995
|
+
this.captureSnapshot(false);
|
|
996
|
+
handle = this.targets.get(grant.targetId);
|
|
997
|
+
if (!fingerprint ||
|
|
998
|
+
fingerprint !== grant.targetFingerprint ||
|
|
999
|
+
grant.pageId !== this.pageId ||
|
|
1000
|
+
grant.pageUrl !== safePageUrl() ||
|
|
1001
|
+
grant.policyVersion !== this.policyVersion ||
|
|
1002
|
+
!revisionMatchesOrStableNavigation ||
|
|
1003
|
+
handle !== fingerprintedHandle ||
|
|
661
1004
|
!handle ||
|
|
662
1005
|
handle.revision !== this.revision ||
|
|
663
1006
|
!handle.element.isConnected) {
|
|
@@ -683,7 +1026,9 @@ class PageContextCollector {
|
|
|
683
1026
|
!isRendered(element) ||
|
|
684
1027
|
(grant.action !== 'scroll' && !isVisible(element) && !canRevealDesiredStateTarget) ||
|
|
685
1028
|
!enabled(element) ||
|
|
686
|
-
(grant.action !== 'scroll' &&
|
|
1029
|
+
(grant.action !== 'scroll' &&
|
|
1030
|
+
isVisible(element) &&
|
|
1031
|
+
isCovered(element, this.trustedGuideRoot))) {
|
|
687
1032
|
return {
|
|
688
1033
|
...base,
|
|
689
1034
|
status: 'blocked',
|
|
@@ -696,9 +1041,17 @@ class PageContextCollector {
|
|
|
696
1041
|
};
|
|
697
1042
|
}
|
|
698
1043
|
if (!isVisible(element) && canRevealDesiredStateTarget) {
|
|
1044
|
+
const preRevealStale = executionStale();
|
|
1045
|
+
if (preRevealStale)
|
|
1046
|
+
return preRevealStale;
|
|
699
1047
|
element.scrollIntoView({ behavior: 'auto', block: 'center' });
|
|
700
1048
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
701
|
-
|
|
1049
|
+
const postRevealStale = executionStale();
|
|
1050
|
+
if (postRevealStale)
|
|
1051
|
+
return postRevealStale;
|
|
1052
|
+
if (!isRendered(element) ||
|
|
1053
|
+
!isVisible(element) ||
|
|
1054
|
+
isCovered(element, this.trustedGuideRoot)) {
|
|
702
1055
|
return {
|
|
703
1056
|
...base,
|
|
704
1057
|
status: 'blocked',
|
|
@@ -711,11 +1064,18 @@ class PageContextCollector {
|
|
|
711
1064
|
};
|
|
712
1065
|
}
|
|
713
1066
|
}
|
|
1067
|
+
const preHighlightStale = executionStale();
|
|
1068
|
+
if (preHighlightStale)
|
|
1069
|
+
return preHighlightStale;
|
|
714
1070
|
const before = this.safeState(element);
|
|
1071
|
+
const beforeActionUrl = safePageUrl();
|
|
1072
|
+
this.callbacks.onHighlight(element, 'focus');
|
|
1073
|
+
const preMutationStale = executionStale();
|
|
1074
|
+
if (preMutationStale)
|
|
1075
|
+
return preMutationStale;
|
|
715
1076
|
this.executingActionId = grant.actionId;
|
|
716
1077
|
this.pendingCapture = false;
|
|
717
1078
|
this.deferredNavigation = null;
|
|
718
|
-
this.callbacks.onHighlight(element, 'focus');
|
|
719
1079
|
try {
|
|
720
1080
|
switch (grant.action) {
|
|
721
1081
|
case 'scroll':
|
|
@@ -732,7 +1092,7 @@ class PageContextCollector {
|
|
|
732
1092
|
element.focus({ preventScroll: false });
|
|
733
1093
|
break;
|
|
734
1094
|
case 'click':
|
|
735
|
-
this.click(element, grant.actionId, grant.desiredChecked,
|
|
1095
|
+
this.click(element, grant.actionId, grant.desiredChecked, grant.desiredExpanded, grant.desiredPressed, grant.destinationPath);
|
|
736
1096
|
break;
|
|
737
1097
|
case 'type':
|
|
738
1098
|
this.type(element, grant.value ?? '');
|
|
@@ -744,13 +1104,29 @@ class PageContextCollector {
|
|
|
744
1104
|
this.submit(element);
|
|
745
1105
|
break;
|
|
746
1106
|
}
|
|
1107
|
+
if (['click', 'type', 'select', 'submit'].includes(grant.action)) {
|
|
1108
|
+
this.callbacks.onActionMutationApplied?.(grant.actionId, this.revision, safePageUrl());
|
|
1109
|
+
}
|
|
1110
|
+
if (grant.action === 'click' &&
|
|
1111
|
+
typeof grant.destinationPath === 'string') {
|
|
1112
|
+
this.observeCausalNavigationAfterMutation(grant.actionId, grant.destinationPath, beforeActionUrl);
|
|
1113
|
+
}
|
|
747
1114
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1115
|
+
const postActionStale = executionStale();
|
|
1116
|
+
if (postActionStale)
|
|
1117
|
+
return postActionStale;
|
|
748
1118
|
if (!this.actionApplied(grant, element, handle)) {
|
|
749
1119
|
throw new Error('The approved value was not applied.');
|
|
750
1120
|
}
|
|
751
1121
|
const snapshot = this.capture();
|
|
752
1122
|
const attached = element.isConnected;
|
|
753
1123
|
const after = attached ? this.safeState(element) : '';
|
|
1124
|
+
const expanded = attached
|
|
1125
|
+
? literalBooleanAttribute(element, 'aria-expanded')
|
|
1126
|
+
: undefined;
|
|
1127
|
+
const pressed = attached
|
|
1128
|
+
? literalBooleanAttribute(element, 'aria-pressed')
|
|
1129
|
+
: undefined;
|
|
754
1130
|
this.callbacks.onHighlight(attached ? element : null, 'success');
|
|
755
1131
|
return {
|
|
756
1132
|
...base,
|
|
@@ -769,10 +1145,14 @@ class PageContextCollector {
|
|
|
769
1145
|
? 'The requested option was applied to the approved field.'
|
|
770
1146
|
: grant.desiredChecked !== undefined
|
|
771
1147
|
? 'The requested checkbox state was applied.'
|
|
772
|
-
:
|
|
1148
|
+
: grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined
|
|
1149
|
+
? 'The requested button state was applied.'
|
|
1150
|
+
: `${grant.action} executed on the approved ${roleFor(element)} target.`,
|
|
773
1151
|
...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
|
|
774
1152
|
? { checked: element.checked }
|
|
775
1153
|
: {}),
|
|
1154
|
+
...(this.statefulButtonActionProof && expanded !== undefined ? { expanded } : {}),
|
|
1155
|
+
...(this.statefulButtonActionProof && pressed !== undefined ? { pressed } : {}),
|
|
776
1156
|
},
|
|
777
1157
|
};
|
|
778
1158
|
}
|
|
@@ -798,10 +1178,25 @@ class PageContextCollector {
|
|
|
798
1178
|
this.pendingNavigation = null;
|
|
799
1179
|
const shouldCapture = this.pendingCapture || Boolean(navigation);
|
|
800
1180
|
this.pendingCapture = false;
|
|
1181
|
+
let navigationAcknowledged = true;
|
|
1182
|
+
const settlement = navigation && this.running
|
|
1183
|
+
? this.beginNavigationSettlement(navigation.pageId, Math.max(navigation.revision, this.revision), navigation.url)
|
|
1184
|
+
: null;
|
|
801
1185
|
if (navigation && this.running) {
|
|
802
|
-
|
|
1186
|
+
try {
|
|
1187
|
+
navigationAcknowledged =
|
|
1188
|
+
(await this.callbacks.onNavigation(navigation.pageId, Math.max(navigation.revision, this.revision), navigation.url, navigation.causalActionId)) !== false;
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
navigationAcknowledged = false;
|
|
1192
|
+
}
|
|
803
1193
|
}
|
|
804
|
-
if (this.running &&
|
|
1194
|
+
if (this.running &&
|
|
1195
|
+
navigationAcknowledged &&
|
|
1196
|
+
(!settlement || this.navigationSettlement === settlement) &&
|
|
1197
|
+
(shouldCapture || !navigation)) {
|
|
1198
|
+
if (settlement)
|
|
1199
|
+
this.navigationSettlement = null;
|
|
805
1200
|
await this.captureAndPublish();
|
|
806
1201
|
}
|
|
807
1202
|
const deferred = this.deferredNavigation;
|
|
@@ -810,19 +1205,39 @@ class PageContextCollector {
|
|
|
810
1205
|
? deferred.toUrl
|
|
811
1206
|
: null;
|
|
812
1207
|
}
|
|
813
|
-
click(element, actionId, desiredChecked, expectedDestinationPath) {
|
|
1208
|
+
click(element, actionId, desiredChecked, desiredExpanded, desiredPressed, expectedDestinationPath) {
|
|
814
1209
|
if (!(element instanceof HTMLElement))
|
|
815
1210
|
throw new Error('Target is not clickable.');
|
|
1211
|
+
if (desiredChecked !== undefined &&
|
|
1212
|
+
(desiredExpanded !== undefined || desiredPressed !== undefined)) {
|
|
1213
|
+
throw new Error('Conflicting desired-state contracts are not allowed.');
|
|
1214
|
+
}
|
|
1215
|
+
const desiredStates = [];
|
|
816
1216
|
if (desiredChecked !== undefined) {
|
|
817
1217
|
if (!(element instanceof HTMLInputElement) || !['checkbox', 'radio'].includes(element.type)) {
|
|
818
1218
|
throw new Error('Desired checked state requires a checkbox or radio target.');
|
|
819
1219
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if (element.type === 'radio' && !desiredChecked) {
|
|
1220
|
+
desiredStates.push(element.checked === desiredChecked);
|
|
1221
|
+
if (element.type === 'radio' && !desiredChecked && element.checked !== desiredChecked) {
|
|
823
1222
|
throw new Error('A radio target cannot be cleared by clicking it.');
|
|
824
1223
|
}
|
|
825
1224
|
}
|
|
1225
|
+
if (desiredExpanded !== undefined) {
|
|
1226
|
+
const expanded = literalBooleanAttribute(element, 'aria-expanded');
|
|
1227
|
+
if (roleFor(element) !== 'button' || expanded === undefined) {
|
|
1228
|
+
throw new Error('Desired expanded state requires an expandable button target.');
|
|
1229
|
+
}
|
|
1230
|
+
desiredStates.push(expanded === desiredExpanded);
|
|
1231
|
+
}
|
|
1232
|
+
if (desiredPressed !== undefined) {
|
|
1233
|
+
const pressed = literalBooleanAttribute(element, 'aria-pressed');
|
|
1234
|
+
if (roleFor(element) !== 'button' || pressed === undefined) {
|
|
1235
|
+
throw new Error('Desired pressed state requires a pressed button target.');
|
|
1236
|
+
}
|
|
1237
|
+
desiredStates.push(pressed === desiredPressed);
|
|
1238
|
+
}
|
|
1239
|
+
if (desiredStates.length > 0 && desiredStates.every(Boolean))
|
|
1240
|
+
return;
|
|
826
1241
|
if (element instanceof HTMLAnchorElement) {
|
|
827
1242
|
const target = new URL(element.href, window.location.href);
|
|
828
1243
|
if (expectedDestinationPath !== undefined &&
|
|
@@ -862,6 +1277,8 @@ class PageContextCollector {
|
|
|
862
1277
|
type(element, value) {
|
|
863
1278
|
if (value.length > 2000)
|
|
864
1279
|
throw new Error('Value is too long.');
|
|
1280
|
+
if (!enabled(element))
|
|
1281
|
+
throw new Error('Text target is not editable.');
|
|
865
1282
|
if (element instanceof HTMLInputElement) {
|
|
866
1283
|
if (!['text', 'search', 'email', 'tel', 'url', 'number'].includes(element.type)) {
|
|
867
1284
|
throw new Error('Input type is not supported.');
|
|
@@ -899,14 +1316,24 @@ class PageContextCollector {
|
|
|
899
1316
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
900
1317
|
}
|
|
901
1318
|
select(element, handle, optionValueId) {
|
|
902
|
-
if (!(element instanceof HTMLSelectElement) || !optionValueId) {
|
|
1319
|
+
if (!(element instanceof HTMLSelectElement) || !optionValueId || !enabled(element)) {
|
|
903
1320
|
throw new Error('Select target is invalid.');
|
|
904
1321
|
}
|
|
905
1322
|
const option = handle.options.get(optionValueId);
|
|
906
|
-
if (!option ||
|
|
1323
|
+
if (!option ||
|
|
1324
|
+
optionDisabled(option) ||
|
|
1325
|
+
!option.isConnected ||
|
|
1326
|
+
option.closest('select') !== element) {
|
|
907
1327
|
throw new Error('Select option is unavailable.');
|
|
908
1328
|
}
|
|
909
|
-
|
|
1329
|
+
const selectedIndexSetter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex')?.set;
|
|
1330
|
+
if (!selectedIndexSetter) {
|
|
1331
|
+
throw new Error('Select option cannot be updated safely.');
|
|
1332
|
+
}
|
|
1333
|
+
selectedIndexSetter.call(element, option.index);
|
|
1334
|
+
if (element.options[element.selectedIndex] !== option || !option.selected) {
|
|
1335
|
+
throw new Error('Select option could not be applied.');
|
|
1336
|
+
}
|
|
910
1337
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
911
1338
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
912
1339
|
}
|
|
@@ -928,6 +1355,7 @@ class PageContextCollector {
|
|
|
928
1355
|
connected: element.isConnected,
|
|
929
1356
|
disabled: !enabled(element),
|
|
930
1357
|
expanded: element.getAttribute('aria-expanded'),
|
|
1358
|
+
pressed: element.getAttribute('aria-pressed'),
|
|
931
1359
|
checked: element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
|
|
932
1360
|
? element.checked
|
|
933
1361
|
: undefined,
|
|
@@ -941,6 +1369,17 @@ class PageContextCollector {
|
|
|
941
1369
|
['checkbox', 'radio'].includes(element.type) &&
|
|
942
1370
|
element.checked === grant.desiredChecked);
|
|
943
1371
|
}
|
|
1372
|
+
if (grant.desiredExpanded !== undefined &&
|
|
1373
|
+
literalBooleanAttribute(element, 'aria-expanded') !== grant.desiredExpanded) {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
if (grant.desiredPressed !== undefined &&
|
|
1377
|
+
literalBooleanAttribute(element, 'aria-pressed') !== grant.desiredPressed) {
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
if (grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined) {
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
944
1383
|
if (grant.action === 'type') {
|
|
945
1384
|
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
946
1385
|
return element.value === (grant.value ?? '');
|
|
@@ -952,7 +1391,13 @@ class PageContextCollector {
|
|
|
952
1391
|
}
|
|
953
1392
|
if (grant.action === 'select') {
|
|
954
1393
|
const option = grant.optionValueId ? handle.options.get(grant.optionValueId) : undefined;
|
|
955
|
-
|
|
1394
|
+
if (!(element instanceof HTMLSelectElement) || !option)
|
|
1395
|
+
return false;
|
|
1396
|
+
return (option.isConnected &&
|
|
1397
|
+
option.closest('select') === element &&
|
|
1398
|
+
!optionDisabled(option) &&
|
|
1399
|
+
element.options[element.selectedIndex] === option &&
|
|
1400
|
+
option.selected);
|
|
956
1401
|
}
|
|
957
1402
|
return true;
|
|
958
1403
|
}
|
|
@@ -973,6 +1418,8 @@ class PageContextCollector {
|
|
|
973
1418
|
};
|
|
974
1419
|
}
|
|
975
1420
|
async captureAndPublishIfChanged() {
|
|
1421
|
+
if (this.navigationSettlement)
|
|
1422
|
+
return;
|
|
976
1423
|
const snapshot = this.captureSnapshot(false);
|
|
977
1424
|
if (snapshot && this.running)
|
|
978
1425
|
await this.callbacks.onSnapshot(snapshot);
|
|
@@ -1046,6 +1493,54 @@ class PageContextCollector {
|
|
|
1046
1493
|
capturedAt: new Date().toISOString(),
|
|
1047
1494
|
};
|
|
1048
1495
|
}
|
|
1496
|
+
beginNavigationSettlement(pageId, revision, url) {
|
|
1497
|
+
const settlement = {
|
|
1498
|
+
generation: ++this.navigationSettlementGeneration,
|
|
1499
|
+
pageId,
|
|
1500
|
+
revision,
|
|
1501
|
+
url,
|
|
1502
|
+
};
|
|
1503
|
+
this.navigationSettlement = settlement;
|
|
1504
|
+
if (this.captureTimer)
|
|
1505
|
+
clearTimeout(this.captureTimer);
|
|
1506
|
+
this.captureTimer = null;
|
|
1507
|
+
if (this.mutationCaptureTimer)
|
|
1508
|
+
clearTimeout(this.mutationCaptureTimer);
|
|
1509
|
+
this.mutationCaptureTimer = null;
|
|
1510
|
+
return settlement;
|
|
1511
|
+
}
|
|
1512
|
+
observeCausalNavigationAfterMutation(actionId, destinationPath, beforeActionUrl) {
|
|
1513
|
+
const currentUrl = safePageUrl();
|
|
1514
|
+
if (currentUrl === beforeActionUrl ||
|
|
1515
|
+
window.location.search !== '' ||
|
|
1516
|
+
window.location.hash !== '' ||
|
|
1517
|
+
window.location.pathname !== destinationPath) {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (this.pendingNavigation?.causalActionId !== actionId ||
|
|
1521
|
+
this.pendingNavigation.url !== currentUrl) {
|
|
1522
|
+
this.pageId = id('page');
|
|
1523
|
+
this.revision += 1;
|
|
1524
|
+
this.targets.clear();
|
|
1525
|
+
this.recentSelection = null;
|
|
1526
|
+
this.pendingNavigation = {
|
|
1527
|
+
pageId: this.pageId,
|
|
1528
|
+
revision: this.revision,
|
|
1529
|
+
url: currentUrl,
|
|
1530
|
+
causalActionId: actionId,
|
|
1531
|
+
causalObservationReported: false,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
this.pendingCapture = true;
|
|
1535
|
+
this.reportCausalNavigationObservation();
|
|
1536
|
+
}
|
|
1537
|
+
reportCausalNavigationObservation() {
|
|
1538
|
+
const navigation = this.pendingNavigation;
|
|
1539
|
+
if (!navigation || navigation.causalObservationReported)
|
|
1540
|
+
return;
|
|
1541
|
+
navigation.causalObservationReported = true;
|
|
1542
|
+
this.callbacks.onCausalNavigationObserved?.(navigation.pageId, navigation.revision, navigation.url, navigation.causalActionId);
|
|
1543
|
+
}
|
|
1049
1544
|
installNavigationHooks() {
|
|
1050
1545
|
this.originalPushState = history.pushState;
|
|
1051
1546
|
this.originalReplaceState = history.replaceState;
|