@mobileaidev/ai-app-bridge-web 0.1.0 → 0.3.0-rc.2
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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +82 -3
- package/package.json +4 -2
- package/src/index.js +1014 -318
package/src/index.js
CHANGED
|
@@ -5,273 +5,376 @@
|
|
|
5
5
|
}
|
|
6
6
|
root.AiAppBridgeWeb = factory();
|
|
7
7
|
}(typeof globalThis !== 'undefined' ? globalThis : this, function factory() {
|
|
8
|
-
const defaultControlSelector = 'a,button,input,textarea,select,[role],[onclick],[aria-label]';
|
|
8
|
+
const defaultControlSelector = 'a,button,input,textarea,select,[contenteditable="true"],[role],[onclick],[aria-label]';
|
|
9
|
+
const dialogLikeSelector = 'dialog,[role="dialog"],[role="alertdialog"],[aria-modal="true"]';
|
|
9
10
|
const maxCaptureText = 12000;
|
|
10
11
|
|
|
12
|
+
const webProtocol = 'aab.web/v2';
|
|
13
|
+
const executionSchema = 'aab.web-execution/v1';
|
|
14
|
+
const domTargetSchema = 'aab.web-dom-target/v1';
|
|
15
|
+
const captureSchema = 'aab.web-capture/v1';
|
|
16
|
+
const captureStreams = ['logs', 'network', 'state', 'events'];
|
|
17
|
+
const documentTargets = new WeakMap();
|
|
18
|
+
const elementKeys = ['elementId', 'tag', 'id', 'name', 'type', 'role', 'ariaLabel', 'placeholder', 'href', 'text'];
|
|
19
|
+
const textInputTypes = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
|
|
20
|
+
const checkedRoles = ['checkbox', 'menuitemcheckbox', 'radio', 'menuitemradio', 'switch', 'option', 'treeitem'];
|
|
21
|
+
const transportLimits = { messageBytes: 256 * 1024, queueBytes: 1024 * 1024, queueCount: 100, queueTtlMs: 60000 };
|
|
22
|
+
|
|
11
23
|
function createAiAppBridge(options = {}) {
|
|
12
24
|
const state = {
|
|
13
|
-
options,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
stateProviders: new Map(),
|
|
21
|
-
restores: [],
|
|
25
|
+
options, socket: null, binding: null, reconnectTimer: null, queue: [], queueBytes: 0,
|
|
26
|
+
started: false, connected: false, runtimeEpoch: newIdentity(),
|
|
27
|
+
sessionId: options.sessionId ?? storedSessionId(options.storageKey ?? 'ai_app_bridge_web_session_id'),
|
|
28
|
+
actions: new Map(), stateProviders: new Map(), restores: [], active: null, completion: null,
|
|
29
|
+
dropped: 0, rejected: 0, lastReceipt: null, lastError: null, reconnectDelay: 1000,
|
|
30
|
+
captureActionId: null, flushUi: null,
|
|
31
|
+
captures: Object.fromEntries(captureStreams.map(stream => [stream, { sequence: 0, losses: 0, pending: 0 }])),
|
|
22
32
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
recordNetwork,
|
|
29
|
-
recordState,
|
|
30
|
-
recordEvent,
|
|
31
|
-
registerAction,
|
|
32
|
-
unregisterAction,
|
|
33
|
-
registerStateProvider,
|
|
34
|
-
snapshotDom: (snapshotOptions) => snapshotDom(snapshotOptions),
|
|
35
|
-
sessionId: () => state.sessionId,
|
|
33
|
+
if (typeof state.sessionId !== 'string' || !state.sessionId || state.sessionId.length > 1024)
|
|
34
|
+
throw new Error('invalid_web_session_id');
|
|
35
|
+
const api = { start, stop: disconnect, disconnect, recordLog, recordNetwork, recordState, recordEvent,
|
|
36
|
+
registerAction, unregisterAction, registerStateProvider, snapshotDom: observeDom,
|
|
37
|
+
sessionId: () => state.sessionId, runtimeEpoch: () => state.runtimeEpoch,
|
|
36
38
|
isConnected: () => state.connected,
|
|
39
|
+
transportStatus: () => ({ connected: state.connected, queuedRecords: state.queue.length, queuedBytes: state.queueBytes,
|
|
40
|
+
droppedRecords: state.dropped, rejectedRecords: state.rejected, lastReceipt: state.lastReceipt, lastError: state.lastError,
|
|
41
|
+
activeActionId: state.active?.actionId ?? null, completionPending: Boolean(state.completion) }),
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
function start() {
|
|
40
45
|
if (state.started) return api;
|
|
41
|
-
|
|
42
|
-
installCaptures();
|
|
43
|
-
connect();
|
|
44
|
-
return api;
|
|
46
|
+
if (typeof options.endpoint !== 'string' || !options.endpoint) throw new Error('AiAppBridge endpoint is required');
|
|
47
|
+
state.started = true; state.restores.push(trackDocument()); installCaptures(); connect(); return api;
|
|
45
48
|
}
|
|
46
|
-
|
|
47
49
|
function disconnect() {
|
|
48
|
-
state.
|
|
49
|
-
state.
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
restore();
|
|
53
|
-
} catch (_) {
|
|
54
|
-
// Best-effort cleanup only.
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (state.socket) {
|
|
58
|
-
try {
|
|
59
|
-
state.socket.close();
|
|
60
|
-
} catch (_) {
|
|
61
|
-
// Ignore close failures.
|
|
62
|
-
}
|
|
50
|
+
state.active?.cancel('web_sdk_stopped');
|
|
51
|
+
for (const restore of state.restores.splice(0).reverse()) {
|
|
52
|
+
try { restore(); } catch (error) { state.lastError = error.message; }
|
|
63
53
|
}
|
|
64
|
-
state.
|
|
54
|
+
state.started = false; state.connected = false; state.binding = null;
|
|
55
|
+
clearTimeout(state.reconnectTimer); state.reconnectTimer = null;
|
|
56
|
+
const socket = state.socket; state.socket = null;
|
|
57
|
+
if (socket) socket.close();
|
|
65
58
|
}
|
|
66
|
-
|
|
67
59
|
function connect() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const WebSocketCtor = options.WebSocket || globalValue('WebSocket');
|
|
60
|
+
if (!state.started) return;
|
|
61
|
+
const WebSocketCtor = options.WebSocket ?? globalValue('WebSocket');
|
|
71
62
|
if (!WebSocketCtor) throw new Error('WebSocket is not available');
|
|
72
|
-
const socket = new WebSocketCtor(withToken(endpoint, options.token));
|
|
73
|
-
state.socket = socket;
|
|
63
|
+
const socket = new WebSocketCtor(withToken(options.endpoint, options.token)); state.socket = socket;
|
|
74
64
|
socket.onopen = () => {
|
|
75
|
-
state.
|
|
76
|
-
send({
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
appName: options.appName || documentTitle(),
|
|
80
|
-
url: locationHref(),
|
|
81
|
-
origin: locationOrigin(),
|
|
82
|
-
route: locationPath(),
|
|
83
|
-
targetId: 'main',
|
|
84
|
-
capabilities: {
|
|
85
|
-
logs: true,
|
|
86
|
-
network: true,
|
|
87
|
-
state: true,
|
|
88
|
-
events: true,
|
|
89
|
-
dom: true,
|
|
90
|
-
command: true,
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
flushQueue();
|
|
65
|
+
if (state.socket !== socket || !state.started) return;
|
|
66
|
+
socket.send(JSON.stringify({ type: 'hello', schemaVersion: webProtocol, sessionId: state.sessionId,
|
|
67
|
+
runtimeEpoch: state.runtimeEpoch, targetId: 'main', executionSchema, domTargetSchema, captureSchema,
|
|
68
|
+
appName: options.appName ?? documentTitle(), url: locationHref(), origin: locationOrigin(), route: locationPath() }));
|
|
94
69
|
};
|
|
95
|
-
socket.onmessage =
|
|
96
|
-
|
|
97
|
-
state.
|
|
98
|
-
if (state.started && options.reconnect !== false) {
|
|
99
|
-
setTimeout(connect, Number(options.reconnectDelayMs || 1000));
|
|
100
|
-
}
|
|
70
|
+
socket.onmessage = event => {
|
|
71
|
+
if (state.socket !== socket || !state.started) return;
|
|
72
|
+
void handleServerMessage(event.data).catch(error => { state.lastError = error.message; disconnect(); });
|
|
101
73
|
};
|
|
102
|
-
socket.
|
|
103
|
-
state.
|
|
74
|
+
socket.onclose = event => {
|
|
75
|
+
if (state.socket !== socket) return;
|
|
76
|
+
state.connected = false; state.binding = null; state.socket = null;
|
|
77
|
+
if (event?.code === 1008) { state.lastError = 'web_protocol_rejected'; disconnect(); return; }
|
|
78
|
+
if (state.started && options.reconnect !== false && state.reconnectTimer === null) {
|
|
79
|
+
state.reconnectTimer = setTimeout(() => { state.reconnectTimer = null; connect(); }, state.reconnectDelay);
|
|
80
|
+
state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000);
|
|
81
|
+
}
|
|
104
82
|
};
|
|
83
|
+
socket.onerror = () => { if (state.socket === socket) state.lastError = 'web_transport_error'; };
|
|
105
84
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
state.queue.push(text);
|
|
114
|
-
while (state.queue.length > 100) state.queue.shift();
|
|
85
|
+
function sendWire(payload) {
|
|
86
|
+
if (!state.connected || !state.binding || state.socket?.readyState !== 1) return false;
|
|
87
|
+
const wire = JSON.stringify({ ...payload, binding: state.binding });
|
|
88
|
+
if (utf8Bytes(wire) > transportLimits.messageBytes) throw new Error('web_message_too_large');
|
|
89
|
+
if ((state.socket.bufferedAmount ?? 0) > transportLimits.queueBytes) { state.lastError = 'web_socket_backpressure'; state.socket.close(); return false; }
|
|
90
|
+
state.socket.send(wire); return true;
|
|
115
91
|
}
|
|
116
|
-
|
|
117
92
|
function flushQueue() {
|
|
118
|
-
while (state.queue.length
|
|
119
|
-
state.
|
|
93
|
+
while (state.queue.length) {
|
|
94
|
+
const entry = state.queue[0];
|
|
95
|
+
if (Date.now() - entry.atMs >= transportLimits.queueTtlMs) {
|
|
96
|
+
state.dropped++; state.captures[entry.payload.stream].losses++;
|
|
97
|
+
}
|
|
98
|
+
else if (!sendWire(entry.payload)) break;
|
|
99
|
+
state.queue.shift(); state.queueBytes -= entry.bytes;
|
|
120
100
|
}
|
|
121
101
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
102
|
+
function capture(stream, item, context) {
|
|
103
|
+
const captureId = newIdentity();
|
|
104
|
+
const explicit = Object.hasOwn(context, 'actionId');
|
|
105
|
+
const actionId = explicit ? context.actionId : state.captureActionId;
|
|
106
|
+
const association = actionId == null ? 'unattributed' : context.association ?? (explicit ? 'explicit' : 'synchronous');
|
|
107
|
+
const counters = state.captures[stream];
|
|
108
|
+
const payload = { type: 'capture', captureId, stream, sequence: ++counters.sequence, actionId,
|
|
109
|
+
item: { source: 'web-sdk', timestampMs: Date.now(), url: locationHref(), route: locationPath(), ...item,
|
|
110
|
+
...(context.pageRef === undefined ? {} : { pageRef: context.pageRef }), association } };
|
|
111
|
+
let bytes;
|
|
112
|
+
try { bytes = utf8Bytes(JSON.stringify(payload)); }
|
|
113
|
+
catch { state.rejected++; counters.losses++; return { accepted: false, stored: false, captureId, error: 'invalid_web_capture_json' }; }
|
|
114
|
+
if (bytes > 128 * 1024 - 4096) { state.rejected++; counters.losses++; return { accepted: false, stored: false, captureId, error: 'web_record_too_large' }; }
|
|
115
|
+
while (state.queue.length && (state.queue.length >= transportLimits.queueCount || state.queueBytes + bytes > transportLimits.queueBytes)) {
|
|
116
|
+
const dropped = state.queue.shift();
|
|
117
|
+
state.queueBytes -= dropped.bytes; state.dropped++; state.captures[dropped.payload.stream].losses++;
|
|
118
|
+
}
|
|
119
|
+
state.queue.push({ payload, bytes, atMs: Date.now() }); state.queueBytes += bytes; flushQueue();
|
|
120
|
+
return { accepted: true, stored: false, captureId, runtimeEpoch: state.runtimeEpoch };
|
|
135
121
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
capture('logs', {
|
|
139
|
-
level: level || 'info',
|
|
140
|
-
tag: tag || 'web',
|
|
141
|
-
message: trimText(message),
|
|
142
|
-
data,
|
|
143
|
-
source: 'web-sdk',
|
|
144
|
-
});
|
|
122
|
+
function recordLog(level, tag, message, data, context = {}) {
|
|
123
|
+
return capture('logs', { level: level ?? 'info', tag: tag ?? 'web', message: trimText(message), data }, context);
|
|
145
124
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
requestHeaders: record.requestHeaders,
|
|
154
|
-
responseHeaders: record.responseHeaders,
|
|
155
|
-
requestBody: trimText(record.requestBody),
|
|
156
|
-
responseBody: trimText(record.responseBody),
|
|
157
|
-
error: record.error,
|
|
158
|
-
redacted: true,
|
|
159
|
-
source: record.source || 'web-sdk',
|
|
160
|
-
});
|
|
125
|
+
function recordNetwork(record, context = {}) {
|
|
126
|
+
return capture('network', { method: record.method ?? 'GET', url: record.url ?? '', statusCode: record.statusCode ?? -1,
|
|
127
|
+
durationMs: record.durationMs ?? -1, requestHeaders: record.requestHeaders, responseHeaders: record.responseHeaders,
|
|
128
|
+
requestBody: captureText(record.requestBody, record.requestBodyEncoding), responseBody: captureText(record.responseBody, record.responseBodyEncoding), error: record.error,
|
|
129
|
+
requestBodyState: record.requestBodyState, responseBodyState: record.responseBodyState,
|
|
130
|
+
requestBodyEncoding: record.requestBodyEncoding, responseBodyEncoding: record.responseBodyEncoding,
|
|
131
|
+
redacted: record.redacted === true, source: record.source ?? 'web-sdk' }, context);
|
|
161
132
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
capture('state', {
|
|
165
|
-
namespace: namespace || 'app',
|
|
166
|
-
key: key || 'value',
|
|
167
|
-
value,
|
|
168
|
-
});
|
|
133
|
+
function recordState(namespace, key, value, context = {}) {
|
|
134
|
+
return capture('state', { namespace, key, value }, context);
|
|
169
135
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
capture('events', {
|
|
173
|
-
category: category || 'app',
|
|
174
|
-
name: name || 'event',
|
|
175
|
-
data,
|
|
176
|
-
});
|
|
136
|
+
function recordEvent(category, name, data, context = {}) {
|
|
137
|
+
return capture('events', { category: category ?? 'app', name: name ?? 'event', data }, context);
|
|
177
138
|
}
|
|
178
|
-
|
|
179
139
|
function registerAction(name, handler) {
|
|
180
|
-
if (!name || typeof handler !== 'function')
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
state.actions.set(String(name), handler);
|
|
184
|
-
return api;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function unregisterAction(name) {
|
|
188
|
-
state.actions.delete(String(name));
|
|
189
|
-
return api;
|
|
140
|
+
if (typeof name !== 'string' || !name || name.length > 1024 || typeof handler !== 'function') throw new Error('registerAction requires a name and handler');
|
|
141
|
+
if (!state.actions.has(name) && state.actions.size >= 128) throw new Error('web_action_registration_limit');
|
|
142
|
+
state.actions.set(name, handler); return api;
|
|
190
143
|
}
|
|
191
|
-
|
|
144
|
+
function unregisterAction(name) { state.actions.delete(name); return api; }
|
|
192
145
|
function registerStateProvider(name, provider) {
|
|
193
|
-
if (!name || typeof provider !== 'function')
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
state.stateProviders.set(String(name), provider);
|
|
197
|
-
return api;
|
|
146
|
+
if (typeof name !== 'string' || !name || name.length > 1024 || typeof provider !== 'function') throw new Error('registerStateProvider requires a name and provider');
|
|
147
|
+
if (!state.stateProviders.has(name) && state.stateProviders.size >= 128) throw new Error('web_state_provider_limit');
|
|
148
|
+
state.stateProviders.set(name, provider); return api;
|
|
198
149
|
}
|
|
199
|
-
|
|
150
|
+
function target() { return { sessionId: state.sessionId, runtimeEpoch: state.runtimeEpoch, targetId: 'main' }; }
|
|
151
|
+
function validBinding(binding) {
|
|
152
|
+
return binding && binding.schemaVersion === webProtocol && binding.sessionId === state.sessionId
|
|
153
|
+
&& binding.runtimeEpoch === state.runtimeEpoch && binding.targetId === 'main'
|
|
154
|
+
&& typeof binding.providerEpoch === 'string' && typeof binding.connectionId === 'string';
|
|
155
|
+
}
|
|
156
|
+
function reply(request, result) { sendWire({ type: 'response', requestId: request.requestId, result }); }
|
|
157
|
+
function sendCompletion() { if (state.completion) sendWire({ type: 'completion', result: state.completion }); }
|
|
200
158
|
async function handleServerMessage(raw) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
159
|
+
if (typeof raw !== 'string' || utf8Bytes(raw) > transportLimits.messageBytes) throw new Error('invalid_web_message');
|
|
160
|
+
const message = JSON.parse(raw);
|
|
161
|
+
if (message.type === 'helloAck') {
|
|
162
|
+
if (state.binding || message.ok !== true || !validBinding(message.binding)) throw new Error('invalid_web_binding');
|
|
163
|
+
state.binding = message.binding; state.connected = true; state.reconnectDelay = 1000;
|
|
164
|
+
sendCompletion(); flushQueue(); return;
|
|
165
|
+
}
|
|
166
|
+
if (!state.binding || JSON.stringify(message.binding) !== JSON.stringify(state.binding)) throw new Error('web_binding_mismatch');
|
|
167
|
+
if (message.type === 'captureAck') {
|
|
168
|
+
state.lastReceipt = { captureId: message.captureId, ...message.receipt };
|
|
169
|
+
if (!message.receipt?.stored) { state.rejected++; state.lastError = message.error; } return;
|
|
170
|
+
}
|
|
171
|
+
if (message.type === 'completionAck') {
|
|
172
|
+
if (state.completion?.actionId === message.actionId && message.stored === true) state.completion = null;
|
|
173
|
+
else if (message.stored !== true) state.lastError = message.error;
|
|
205
174
|
return;
|
|
206
175
|
}
|
|
207
|
-
if (message.type
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
commandId: message.commandId,
|
|
221
|
-
ok: false,
|
|
222
|
-
error: error.message || String(error),
|
|
223
|
-
});
|
|
176
|
+
if (message.type === 'read') {
|
|
177
|
+
if (message.payload?.name === 'captureBarrier') {
|
|
178
|
+
const stream = message.payload.args?.stream;
|
|
179
|
+
if (!captureStreams.includes(stream)) { reply(message, { ok: false, error: 'invalid_web_capture_stream' }); return; }
|
|
180
|
+
if (stream === 'events' && state.flushUi) state.flushUi();
|
|
181
|
+
flushQueue();
|
|
182
|
+
reply(message, { ok: true, schemaVersion: captureSchema, stream,
|
|
183
|
+
...state.captures[stream], target: target() });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (message.payload?.name !== 'domSnapshot') { reply(message, { ok: false, error: 'unknown_web_read' }); return; }
|
|
187
|
+
const dom = observeDom(message.payload.args ?? {});
|
|
188
|
+
reply(message, dom.ok ? { ok: true, dom } : dom); return;
|
|
224
189
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
190
|
+
if (message.type === 'cancel') {
|
|
191
|
+
const identity = message.payload;
|
|
192
|
+
if (identity?.runtimeEpoch !== state.runtimeEpoch) { reply(message, { ok: false, error: 'web_target_changed' }); return; }
|
|
193
|
+
if (state.active?.actionId === identity.actionId) state.active.cancel('web_action_cancelled');
|
|
194
|
+
sendCompletion(); reply(message, { ok: true, actionId: identity.actionId, runtimeEpoch: state.runtimeEpoch }); return;
|
|
195
|
+
}
|
|
196
|
+
if (message.type !== 'command') throw new Error('unknown_web_message');
|
|
197
|
+
const request = message.payload, execution = request?.execution;
|
|
198
|
+
const reject = error => reply(message, { ok: false, error, dispatched: false, ambiguous: false });
|
|
199
|
+
if (execution?.schemaVersion !== executionSchema || execution.runtimeEpoch !== state.runtimeEpoch
|
|
200
|
+
|| typeof execution.actionId !== 'string' || request.actionId !== execution.actionId
|
|
201
|
+
|| !Number.isInteger(execution.timeoutMs) || execution.timeoutMs < 1 || execution.timeoutMs > 2147483647
|
|
202
|
+
|| JSON.stringify(request.target) !== JSON.stringify(target())) { reject('invalid_web_execution'); return; }
|
|
203
|
+
if (state.active || state.completion) { sendCompletion(); reject('web_action_busy'); return; }
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const task = { actionId: request.actionId, issued: false, stopReason: null, timer: null, queued: null,
|
|
206
|
+
deadline: performance.now() + execution.timeoutMs,
|
|
207
|
+
cancel(reason) {
|
|
208
|
+
if (state.active !== task || task.stopReason) return;
|
|
209
|
+
task.stopReason = reason; controller.abort(reason);
|
|
210
|
+
if (!task.issued) { clearTimeout(task.queued); finish({ ok: false, error: reason }); }
|
|
211
|
+
},
|
|
212
|
+
check() {
|
|
213
|
+
if (performance.now() >= task.deadline) task.cancel('web_action_timeout');
|
|
214
|
+
if (state.active !== task || task.stopReason || !state.started) throw new Error(task.stopReason ?? 'web_action_not_active');
|
|
215
|
+
},
|
|
216
|
+
permit() { task.check(); task.issued = true; },
|
|
217
|
+
pageRef: () => documentPageRef(target()),
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
};
|
|
220
|
+
function finish(outcome) {
|
|
221
|
+
if (state.active !== task) return;
|
|
222
|
+
clearTimeout(task.timer); clearTimeout(task.queued);
|
|
223
|
+
let result = { ...outcome, ...(task.stopReason ? { ok: false, error: task.stopReason } : {}),
|
|
224
|
+
actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, settled: true, dispatched: task.issued, ambiguous: false,
|
|
225
|
+
execution: { schemaVersion: executionSchema, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, target: target(), settled: true } };
|
|
226
|
+
try { if (typeof outcome?.ok !== 'boolean' || (outcome.ok === false && typeof outcome.error !== 'string')
|
|
227
|
+
|| utf8Bytes(JSON.stringify(result)) > 64 * 1024) throw new Error(); }
|
|
228
|
+
catch { result = { ok: false, error: 'invalid_web_action_result', actionId: task.actionId,
|
|
229
|
+
runtimeEpoch: state.runtimeEpoch, settled: true, dispatched: task.issued, ambiguous: false,
|
|
230
|
+
execution: { schemaVersion: executionSchema, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch, target: target(), settled: true } }; }
|
|
231
|
+
state.active = null; state.completion = result; sendCompletion();
|
|
232
|
+
}
|
|
233
|
+
state.active = task;
|
|
234
|
+
task.timer = setTimeout(() => task.cancel('web_action_timeout'), execution.timeoutMs);
|
|
235
|
+
task.queued = setTimeout(async () => {
|
|
236
|
+
try {
|
|
237
|
+
task.check();
|
|
238
|
+
let result;
|
|
239
|
+
// This scope covers the synchronous dispatch stack only. Native
|
|
240
|
+
// async/await continuations do not inherit it in current browsers.
|
|
241
|
+
state.captureActionId = task.actionId;
|
|
242
|
+
try { result = runCommand(request.command, task); }
|
|
243
|
+
finally { state.captureActionId = null; }
|
|
244
|
+
finish(await result);
|
|
234
245
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
catch (error) { finish({ ok: false, error: error.message || 'web_action_failed',
|
|
247
|
+
...(error.details === undefined ? {} : { details: error.details }) }); }
|
|
248
|
+
}, 0);
|
|
249
|
+
}
|
|
250
|
+
async function runCommand(command, task) {
|
|
251
|
+
const args = command?.args ?? {};
|
|
252
|
+
switch (command?.name) {
|
|
253
|
+
case 'click': return clickElement(args, task);
|
|
254
|
+
case 'input': return inputElement(args, task);
|
|
255
|
+
case 'key': return keyEvent(args, task);
|
|
256
|
+
case 'waitFor': return waitFor(args, task);
|
|
257
|
+
case 'scroll': return scrollTarget(args, task);
|
|
243
258
|
case 'state': {
|
|
244
259
|
const values = {};
|
|
245
|
-
for (const [name, provider] of state.stateProviders.
|
|
246
|
-
values[name] = await provider();
|
|
247
|
-
}
|
|
260
|
+
for (const [name, provider] of state.stateProviders) { task.permit(); values[name] = await provider({ signal: task.signal }); task.check(); }
|
|
248
261
|
return { ok: true, values };
|
|
249
262
|
}
|
|
250
263
|
case 'action': {
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const handler = state.actions.get(String(command.name || ''));
|
|
258
|
-
if (!handler) return { ok: false, error: 'unknown_command', command: command.name || '' };
|
|
259
|
-
return { ok: true, value: await handler(args) };
|
|
264
|
+
if (typeof args.name !== 'string' || !state.actions.has(args.name)) return { ok: false, error: 'action_not_registered' };
|
|
265
|
+
const context = { signal: task.signal, actionId: task.actionId, runtimeEpoch: state.runtimeEpoch,
|
|
266
|
+
recordState: (namespace, key, value) => recordState(namespace, key, value, { actionId: task.actionId }),
|
|
267
|
+
recordEvent: (category, name, data) => recordEvent(category, name, data, { actionId: task.actionId }) };
|
|
268
|
+
task.permit(); const value = await state.actions.get(args.name)(args.arguments, context);
|
|
269
|
+
return { ok: true, value };
|
|
260
270
|
}
|
|
271
|
+
default: return { ok: false, error: 'unknown_web_command' };
|
|
261
272
|
}
|
|
262
273
|
}
|
|
263
|
-
|
|
274
|
+
function observeDom(options = {}) {
|
|
275
|
+
const dom = snapshotDom(options);
|
|
276
|
+
return dom.ok ? { ...dom, pageRef: documentPageRef(target()) } : dom;
|
|
277
|
+
}
|
|
264
278
|
function installCaptures() {
|
|
265
|
-
const captureOptions = options.capture
|
|
279
|
+
const captureOptions = options.capture ?? {};
|
|
266
280
|
if (captureOptions.console) installConsoleCapture(state, recordLog);
|
|
267
281
|
if (captureOptions.errors) installErrorCapture(state, recordLog);
|
|
268
282
|
if (captureOptions.fetch) installFetchCapture(state, recordNetwork, options);
|
|
269
|
-
if (captureOptions.xhr) installXhrCapture(state, recordNetwork);
|
|
283
|
+
if (captureOptions.xhr) installXhrCapture(state, recordNetwork, options);
|
|
284
|
+
if (captureOptions.ui) installUiCapture(state, recordEvent, captureOptions.ui);
|
|
270
285
|
}
|
|
271
|
-
|
|
272
286
|
return api;
|
|
273
287
|
}
|
|
274
288
|
|
|
289
|
+
function utf8Bytes(value) { return new TextEncoder().encode(value).byteLength; }
|
|
290
|
+
function newIdentity() {
|
|
291
|
+
const bytes = new Uint8Array(16); globalValue('crypto').getRandomValues(bytes);
|
|
292
|
+
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function documentTarget(doc) {
|
|
296
|
+
if (!documentTargets.has(doc)) documentTargets.set(doc, { navigationId: newIdentity(), available: true, elements: new WeakMap(), nextElement: 0 });
|
|
297
|
+
return documentTargets.get(doc);
|
|
298
|
+
}
|
|
299
|
+
function documentPageRef(binding) {
|
|
300
|
+
const doc = globalValue('document');
|
|
301
|
+
if (!doc) throw new Error('document_unavailable');
|
|
302
|
+
const state = documentTarget(doc);
|
|
303
|
+
if (!state.available) throw new Error('web_document_inactive');
|
|
304
|
+
return { schemaVersion: domTargetSchema, ...binding, navigationId: state.navigationId, url: locationHref() };
|
|
305
|
+
}
|
|
306
|
+
function trackDocument() {
|
|
307
|
+
const doc = globalValue('document'), win = globalValue('window');
|
|
308
|
+
if (!doc || !win) return () => {};
|
|
309
|
+
const state = documentTarget(doc), restores = [];
|
|
310
|
+
const advance = () => { state.navigationId = newIdentity(); };
|
|
311
|
+
for (const name of ['pushState', 'replaceState']) {
|
|
312
|
+
const original = win.history?.[name];
|
|
313
|
+
if (typeof original !== 'function') continue;
|
|
314
|
+
const wrapper = function (...args) { const result = original.apply(this, args); advance(); return result; };
|
|
315
|
+
win.history[name] = wrapper;
|
|
316
|
+
restores.push(() => { if (win.history[name] === wrapper) win.history[name] = original; });
|
|
317
|
+
}
|
|
318
|
+
const events = { popstate: advance, hashchange: advance,
|
|
319
|
+
pagehide: () => { state.available = false; advance(); }, pageshow: () => { state.available = true; advance(); } };
|
|
320
|
+
if (typeof win.addEventListener === 'function') for (const [name, callback] of Object.entries(events)) {
|
|
321
|
+
win.addEventListener(name, callback); restores.push(() => win.removeEventListener(name, callback));
|
|
322
|
+
}
|
|
323
|
+
return () => { for (const restore of restores) restore(); };
|
|
324
|
+
}
|
|
325
|
+
function elementIdentity(element, doc = element.ownerDocument || globalValue('document')) {
|
|
326
|
+
const state = documentTarget(doc);
|
|
327
|
+
if (!state.elements.has(element)) state.elements.set(element, `e${++state.nextElement}`);
|
|
328
|
+
const sensitive = isSensitiveInput(element);
|
|
329
|
+
return { elementId: state.elements.get(element), tag: text(element.tagName).toLowerCase(), id: text(element.id),
|
|
330
|
+
name: attr(element, 'name'), type: attr(element, 'type'), role: attr(element, 'role'), ariaLabel: attr(element, 'aria-label'),
|
|
331
|
+
placeholder: attr(element, 'placeholder'), href: trimText(element.href, 500),
|
|
332
|
+
text: trimText(sensitive ? attr(element, 'aria-label') || attr(element, 'placeholder')
|
|
333
|
+
: element.innerText || element.value || element.title || attr(element, 'aria-label'), 300) };
|
|
334
|
+
}
|
|
335
|
+
function elementVisible(element) {
|
|
336
|
+
const win = element.ownerDocument?.defaultView || globalValue('window');
|
|
337
|
+
if (!element.isConnected || !win || typeof win.getComputedStyle !== 'function') return false;
|
|
338
|
+
const style = win.getComputedStyle(element), rect = bounds(element);
|
|
339
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse'
|
|
340
|
+
&& Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
|
|
341
|
+
}
|
|
342
|
+
function elementDisabled(element) {
|
|
343
|
+
return Boolean(element.disabled || attr(element, 'aria-disabled') === 'true' || element.closest?.('[inert]'));
|
|
344
|
+
}
|
|
345
|
+
function elementEditable(element) {
|
|
346
|
+
return !elementDisabled(element) && !element.readOnly && (element.isContentEditable === true
|
|
347
|
+
|| element.tagName === 'TEXTAREA' || element.tagName === 'INPUT' && textInputTypes.includes(element.type));
|
|
348
|
+
}
|
|
349
|
+
function elementChecked(element) {
|
|
350
|
+
if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type)) {
|
|
351
|
+
return element.type === 'checkbox' && element.indeterminate ? 'mixed' : element.checked;
|
|
352
|
+
}
|
|
353
|
+
const role = attr(element, 'role'), value = element.getAttribute('aria-checked');
|
|
354
|
+
if (!checkedRoles.includes(role)) return null;
|
|
355
|
+
if (value === 'true') return true;
|
|
356
|
+
if (value === 'false') return false;
|
|
357
|
+
// WAI-ARIA defines mixed on radio/menuitemradio/switch as false. The raw
|
|
358
|
+
// attribute is retained separately, including invalid or missing tokens.
|
|
359
|
+
if (value === 'mixed') {
|
|
360
|
+
if (['radio', 'menuitemradio', 'switch'].includes(role)) return false;
|
|
361
|
+
if (['checkbox', 'menuitemcheckbox'].includes(role)) return 'mixed';
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
function interactionState(element) {
|
|
366
|
+
if (elementDisabled(element)) return { status: 'disabled' };
|
|
367
|
+
if (!elementVisible(element)) return { status: 'hidden' };
|
|
368
|
+
const doc = element.ownerDocument, win = doc.defaultView, rect = bounds(element);
|
|
369
|
+
const left = Math.max(0, rect.left), top = Math.max(0, rect.top), right = Math.min(win.innerWidth, rect.right), bottom = Math.min(win.innerHeight, rect.bottom);
|
|
370
|
+
if (right <= left || bottom <= top) return { status: 'outside-viewport' };
|
|
371
|
+
const point = { x: (left + right) / 2, y: (top + bottom) / 2 };
|
|
372
|
+
const hit = doc.elementFromPoint(point.x, point.y);
|
|
373
|
+
if (hit === element || element.contains(hit)) return { status: 'ready', point };
|
|
374
|
+
return { status: 'obscured', point, hit: hit ? { tag: text(hit.tagName).toLowerCase(), id: text(hit.id),
|
|
375
|
+
role: attr(hit, 'role'), className: typeof hit.className === 'string' ? trimText(hit.className, 300) : '' } : null };
|
|
376
|
+
}
|
|
377
|
+
|
|
275
378
|
function snapshotDom(options = {}) {
|
|
276
379
|
const doc = options.document || globalValue('document');
|
|
277
380
|
if (!doc || !doc.querySelectorAll) {
|
|
@@ -280,113 +383,186 @@
|
|
|
280
383
|
const selector = options.selector || defaultControlSelector;
|
|
281
384
|
const maxControls = Number(options.maxControls || 200);
|
|
282
385
|
const controls = [];
|
|
283
|
-
const
|
|
386
|
+
const matched = doc.querySelectorAll(selector);
|
|
387
|
+
const elements = Array.prototype.slice.call(matched, 0, maxControls);
|
|
284
388
|
elements.forEach((element, index) => {
|
|
389
|
+
const sensitive = isSensitiveInput(element);
|
|
390
|
+
const elementValue = text(element.value);
|
|
285
391
|
controls.push({
|
|
392
|
+
...elementIdentity(element, doc),
|
|
286
393
|
index,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
href: trimText(element.href, 500),
|
|
296
|
-
disabled: Boolean(element.disabled),
|
|
394
|
+
sensitive,
|
|
395
|
+
...(sensitive ? { valueLength: elementValue.length } : {}),
|
|
396
|
+
disabled: elementDisabled(element),
|
|
397
|
+
visible: elementVisible(element),
|
|
398
|
+
editable: elementEditable(element),
|
|
399
|
+
checked: elementChecked(element),
|
|
400
|
+
ariaChecked: element.getAttribute('aria-checked'),
|
|
401
|
+
interaction: interactionState(element),
|
|
297
402
|
bounds: bounds(element),
|
|
298
403
|
});
|
|
299
404
|
});
|
|
300
405
|
return {
|
|
301
406
|
ok: true,
|
|
407
|
+
targetSchema: domTargetSchema,
|
|
302
408
|
title: text(doc.title),
|
|
303
409
|
url: locationHref(),
|
|
304
410
|
readyState: text(doc.readyState),
|
|
305
411
|
bodyText: trimText(doc.body && doc.body.innerText, 20000),
|
|
412
|
+
bodyTextTruncated: text(doc.body && doc.body.innerText).length > 20000,
|
|
306
413
|
controls,
|
|
307
414
|
controlCount: controls.length,
|
|
415
|
+
truncated: matched.length > maxControls,
|
|
308
416
|
updatedAtMs: Date.now(),
|
|
309
417
|
};
|
|
310
418
|
}
|
|
311
419
|
|
|
312
|
-
function clickElement(args
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
420
|
+
function clickElement(args, task) {
|
|
421
|
+
const selected = actionTarget(args, task);
|
|
422
|
+
ensureTarget(selected, task, { hit: true }); task.permit(); selected.element.click();
|
|
423
|
+
return { ok: true, resolved: selected.ref };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function inputElement(args, task) {
|
|
427
|
+
const selected = actionTarget(args, task), element = selected.element;
|
|
428
|
+
if (typeof args.value !== 'string') return { ok: false, error: 'invalid_web_input_value' };
|
|
429
|
+
const richText = element.isContentEditable === true;
|
|
430
|
+
if (!elementEditable(element))
|
|
431
|
+
return { ok: false, error: 'web_element_not_editable' };
|
|
432
|
+
const value = args.value;
|
|
433
|
+
const doc = element.ownerDocument, win = doc.defaultView;
|
|
434
|
+
ensureTarget(selected, task, { hit: true }); task.permit(); element.focus();
|
|
435
|
+
ensureTarget(selected, task, { focus: true });
|
|
436
|
+
if (richText) {
|
|
437
|
+
const range = doc.createRange(); range.selectNodeContents(element);
|
|
438
|
+
const selection = win.getSelection(); task.permit(); selection.removeAllRanges(); selection.addRange(range);
|
|
439
|
+
ensureTarget(selected, task, { focus: true }); task.permit();
|
|
440
|
+
if (!doc.execCommand('insertText', false, value)) return { ok: false, error: 'web_input_rejected', resolved: selected.ref };
|
|
441
|
+
} else {
|
|
442
|
+
const prototype = element.tagName === 'INPUT' ? win.HTMLInputElement.prototype : win.HTMLTextAreaElement.prototype;
|
|
443
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
|
|
444
|
+
task.permit(); setter.call(element, value);
|
|
445
|
+
ensureTarget(selected, task, { focus: true, edited: true }); task.permit();
|
|
446
|
+
element.dispatchEvent(new win.InputEvent('input', { bubbles: true, inputType: 'insertReplacementText', data: value }));
|
|
447
|
+
ensureTarget(selected, task, { focus: true, edited: true }); task.permit(); element.dispatchEvent(new win.Event('change', { bubbles: true }));
|
|
448
|
+
}
|
|
449
|
+
ensureTarget(selected, task, { edited: true });
|
|
450
|
+
const sensitive = isSensitiveInput(element);
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
resolved: selected.ref,
|
|
454
|
+
sensitive,
|
|
455
|
+
valueLength: (richText ? element.innerText : element.value).length,
|
|
456
|
+
...(sensitive ? {} : { value: richText ? element.innerText : element.value }),
|
|
457
|
+
};
|
|
317
458
|
}
|
|
318
459
|
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
element.
|
|
325
|
-
|
|
326
|
-
|
|
460
|
+
function keyEvent(args, task) {
|
|
461
|
+
if (!['Enter', 'Escape'].includes(args.key)) return { ok: false, error: 'invalid_web_key' };
|
|
462
|
+
const selected = actionTarget(args, task), element = selected.element;
|
|
463
|
+
ensureTarget(selected, task, { hit: true }); task.permit(); element.focus();
|
|
464
|
+
ensureTarget(selected, task, { focus: true });
|
|
465
|
+
const event = new element.ownerDocument.defaultView.KeyboardEvent('keydown', {
|
|
466
|
+
key: args.key, code: args.key, bubbles: true, cancelable: true, composed: true,
|
|
467
|
+
});
|
|
468
|
+
task.permit(); element.dispatchEvent(event);
|
|
469
|
+
return { ok: true, resolved: selected.ref, key: args.key, eventType: 'keydown',
|
|
470
|
+
trusted: event.isTrusted, defaultPrevented: event.defaultPrevented };
|
|
327
471
|
}
|
|
328
472
|
|
|
329
|
-
function waitFor(args
|
|
330
|
-
const timeoutMs =
|
|
331
|
-
const startedAtMs =
|
|
332
|
-
return new Promise((resolve) => {
|
|
473
|
+
function waitFor(args, task) {
|
|
474
|
+
const timeoutMs = args.timeoutMs ?? 5000;
|
|
475
|
+
const startedAtMs = performance.now();
|
|
476
|
+
return new Promise((resolve, reject) => {
|
|
477
|
+
let timer;
|
|
478
|
+
const finish = (error, result) => { clearTimeout(timer); task.signal.removeEventListener('abort', abort); error ? reject(error) : resolve(result); };
|
|
479
|
+
const abort = () => finish(new Error(task.signal.reason));
|
|
333
480
|
const tick = () => {
|
|
334
|
-
|
|
335
|
-
|
|
481
|
+
try { task.check();
|
|
482
|
+
if (args.selector && findElement(args.selector)) {
|
|
483
|
+
finish(null, { ok: true, matched: 'selector' });
|
|
336
484
|
return;
|
|
337
485
|
}
|
|
338
486
|
if (args.targetText && bodyText().includes(String(args.targetText))) {
|
|
339
|
-
|
|
487
|
+
finish(null, { ok: true, matched: 'text' });
|
|
340
488
|
return;
|
|
341
489
|
}
|
|
342
|
-
if (
|
|
343
|
-
|
|
490
|
+
if (performance.now() - startedAtMs >= timeoutMs) {
|
|
491
|
+
finish(null, { ok: false, error: 'wait_timeout' });
|
|
344
492
|
return;
|
|
345
493
|
}
|
|
346
|
-
setTimeout(tick,
|
|
494
|
+
timer = setTimeout(tick, args.intervalMs ?? 250);
|
|
495
|
+
} catch (error) { finish(error); }
|
|
347
496
|
};
|
|
497
|
+
task.signal.addEventListener('abort', abort, { once: true });
|
|
348
498
|
tick();
|
|
349
499
|
});
|
|
350
500
|
}
|
|
351
501
|
|
|
352
|
-
function scrollTarget(args
|
|
353
|
-
const
|
|
354
|
-
const deltaX =
|
|
355
|
-
const deltaY =
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
502
|
+
function scrollTarget(args, task) {
|
|
503
|
+
const selected = args.selector ? actionTarget(args, task) : null;
|
|
504
|
+
const deltaX = args.deltaX ?? 0;
|
|
505
|
+
const deltaY = args.deltaY ?? 0;
|
|
506
|
+
if (args.mode === 'into-view') {
|
|
507
|
+
if (!selected) return { ok: false, error: 'web_selector_required' };
|
|
508
|
+
ensureTarget(selected, task); task.permit(); selected.element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
509
|
+
return { ok: true, resolved: selected.ref };
|
|
510
|
+
}
|
|
511
|
+
if (args.mode !== 'by') return { ok: false, error: 'invalid_web_scroll_mode' };
|
|
512
|
+
if (selected) {
|
|
513
|
+
ensureTarget(selected, task); task.permit(); selected.element.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
|
|
514
|
+
return { ok: true, resolved: selected.ref };
|
|
359
515
|
}
|
|
360
516
|
const win = globalValue('window');
|
|
361
517
|
if (win && win.scrollBy) {
|
|
362
|
-
win.scrollBy(deltaX, deltaY);
|
|
518
|
+
task.permit(); win.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
|
|
363
519
|
return { ok: true, target: 'window' };
|
|
364
520
|
}
|
|
365
521
|
return { ok: false, error: 'scroll_unavailable' };
|
|
366
522
|
}
|
|
367
523
|
|
|
368
|
-
function findElement(
|
|
369
|
-
const doc =
|
|
524
|
+
function findElement(selector) {
|
|
525
|
+
const doc = globalValue('document');
|
|
370
526
|
if (!doc) return null;
|
|
371
|
-
if (
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
const elements =
|
|
375
|
-
|
|
527
|
+
if (!selector || typeof selector !== 'object') throw new Error('invalid_web_selector');
|
|
528
|
+
const identities = ['elementId', 'text', 'ariaLabel', 'css'].filter(key => typeof selector[key] === 'string' && selector[key].length > 0);
|
|
529
|
+
if (identities.length !== 1) throw new Error('invalid_web_selector');
|
|
530
|
+
const elements = doc.querySelectorAll(selector.css ?? defaultControlSelector);
|
|
531
|
+
if (elements.length > 1000) throw new Error('web_selector_scan_limit');
|
|
532
|
+
const matches = Array.from(elements).filter(element => {
|
|
533
|
+
if (!elementVisible(element)) return false;
|
|
534
|
+
const ref = elementIdentity(element);
|
|
535
|
+
return ['elementId', 'text', 'ariaLabel', 'tag', 'role'].every(key => selector[key] === undefined || selector[key] === ref[key]);
|
|
536
|
+
});
|
|
537
|
+
if (matches.length > 1) throw new Error('web_element_ambiguous');
|
|
538
|
+
return matches[0] ?? null;
|
|
376
539
|
}
|
|
377
540
|
|
|
378
|
-
function
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
element.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
541
|
+
function actionTarget(args, task) {
|
|
542
|
+
const element = findElement(args.selector);
|
|
543
|
+
if (!element) throw new Error('web_element_not_found');
|
|
544
|
+
const ref = { pageRef: task.pageRef(), element: elementIdentity(element) };
|
|
545
|
+
if (args.expectedTarget && (!samePage(ref.pageRef, args.expectedTarget.pageRef)
|
|
546
|
+
|| !elementKeys.every(key => ref.element[key] === args.expectedTarget.element[key]))) throw new Error('reobserve_required');
|
|
547
|
+
return { element, ref };
|
|
548
|
+
}
|
|
549
|
+
function samePage(a, b) {
|
|
550
|
+
return ['schemaVersion', 'sessionId', 'runtimeEpoch', 'targetId', 'navigationId', 'url'].every(key => a[key] === b?.[key]);
|
|
551
|
+
}
|
|
552
|
+
function ensureTarget(selected, task, { focus = false, hit = false, edited = false } = {}) {
|
|
553
|
+
const { element, ref } = selected;
|
|
554
|
+
const doc = globalValue('document');
|
|
555
|
+
task.check();
|
|
556
|
+
if (!samePage(task.pageRef(), ref.pageRef)) throw new Error('reobserve_required');
|
|
557
|
+
if (!element.isConnected || element.ownerDocument !== doc || elementDisabled(element) || !elementVisible(element)
|
|
558
|
+
|| (focus && doc.activeElement !== element)) throw new Error('web_element_changed');
|
|
559
|
+
const current = elementIdentity(element);
|
|
560
|
+
if (!elementKeys.filter(key => !edited || key !== 'text').every(key => current[key] === ref.element[key])) throw new Error('reobserve_required');
|
|
561
|
+
if (hit) {
|
|
562
|
+
const interaction = interactionState(element);
|
|
563
|
+
if (interaction.status !== 'ready') throw Object.assign(new Error(interaction.status === 'outside-viewport'
|
|
564
|
+
? 'web_element_outside_viewport' : 'web_element_obscured'), { details: { interaction } });
|
|
565
|
+
}
|
|
390
566
|
}
|
|
391
567
|
|
|
392
568
|
function installConsoleCapture(state, recordLog) {
|
|
@@ -431,38 +607,34 @@
|
|
|
431
607
|
const win = globalValue('window');
|
|
432
608
|
if (!win || typeof win.fetch !== 'function') return;
|
|
433
609
|
const original = win.fetch;
|
|
434
|
-
win.fetch =
|
|
610
|
+
win.fetch = function capturedFetch(input, init = {}) {
|
|
435
611
|
const startedAtMs = Date.now();
|
|
436
612
|
const method = (init && init.method) || (input && input.method) || 'GET';
|
|
437
613
|
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
614
|
+
const context = captureContext(state);
|
|
615
|
+
state.captures.network.pending++;
|
|
616
|
+
const requestBody = captureRequestBody(input, init, options.captureRequestBodies === true);
|
|
617
|
+
const finish = async (response, error) => {
|
|
618
|
+
try {
|
|
619
|
+
const [request, body] = await Promise.all([requestBody,
|
|
620
|
+
response && options.captureResponseBodies === true
|
|
621
|
+
? captureResponseBody(response) : Promise.resolve({ state: 'disabled' })]);
|
|
622
|
+
recordNetwork({ source: 'fetch-auto', method, url,
|
|
623
|
+
statusCode: response ? response.status : -1, durationMs: Date.now() - startedAtMs,
|
|
624
|
+
requestBody: request.text, requestBodyState: request.state,
|
|
625
|
+
responseBody: body.text, responseBodyState: body.state,
|
|
626
|
+
requestBodyEncoding: request.encoding, responseBodyEncoding: body.encoding,
|
|
627
|
+
...(error ? { error: error.message || String(error) } : {}) }, context);
|
|
628
|
+
} finally { state.captures.network.pending--; }
|
|
629
|
+
};
|
|
438
630
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
} catch (_) {
|
|
445
|
-
responseBody = undefined;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
recordNetwork({
|
|
449
|
-
source: 'fetch-auto',
|
|
450
|
-
method,
|
|
451
|
-
url,
|
|
452
|
-
statusCode: response.status,
|
|
453
|
-
durationMs: Date.now() - startedAtMs,
|
|
454
|
-
responseBody,
|
|
455
|
-
});
|
|
456
|
-
return response;
|
|
631
|
+
return original.apply(this, arguments).then(response => {
|
|
632
|
+
// Body capture cannot delay the App's response or consume its body.
|
|
633
|
+
void finish(response, null);
|
|
634
|
+
return response;
|
|
635
|
+
}, error => { void finish(null, error); throw error; });
|
|
457
636
|
} catch (error) {
|
|
458
|
-
|
|
459
|
-
source: 'fetch-auto',
|
|
460
|
-
method,
|
|
461
|
-
url,
|
|
462
|
-
statusCode: -1,
|
|
463
|
-
durationMs: Date.now() - startedAtMs,
|
|
464
|
-
error: error.message || String(error),
|
|
465
|
-
});
|
|
637
|
+
void finish(null, error);
|
|
466
638
|
throw error;
|
|
467
639
|
}
|
|
468
640
|
};
|
|
@@ -471,7 +643,79 @@
|
|
|
471
643
|
});
|
|
472
644
|
}
|
|
473
645
|
|
|
474
|
-
function
|
|
646
|
+
function captureContext(state) {
|
|
647
|
+
return { ...actionContext(state),
|
|
648
|
+
pageRef: documentPageRef({ sessionId: state.sessionId, runtimeEpoch: state.runtimeEpoch, targetId: 'main' }) };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function actionContext(state) {
|
|
652
|
+
return { actionId: state.captureActionId, association: state.captureActionId === null ? 'unattributed' : 'synchronous' };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function captureText(value, encoding) {
|
|
656
|
+
if (value === undefined) return undefined;
|
|
657
|
+
if (typeof value !== 'string') throw new TypeError('Web capture bodies must be strings when supplied.');
|
|
658
|
+
if (encoding === 'base64') {
|
|
659
|
+
if (value.length > Math.ceil(maxCaptureText / 3) * 4) throw new TypeError('Encoded Web bodies exceed the capture limit.');
|
|
660
|
+
return value;
|
|
661
|
+
}
|
|
662
|
+
return trimText(value);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function captureRequestBody(input, init, enabled) {
|
|
666
|
+
if (!enabled) return { state: 'disabled' };
|
|
667
|
+
if (Object.hasOwn(init, 'body')) {
|
|
668
|
+
const body = init.body;
|
|
669
|
+
if (body === null || body === undefined) return { state: 'empty', text: '', encoding: 'utf8' };
|
|
670
|
+
if (typeof body === 'string') return { state: body.length > maxCaptureText ? 'truncated' : 'complete', text: trimText(body), encoding: 'utf8' };
|
|
671
|
+
if (body instanceof URLSearchParams) {
|
|
672
|
+
const text = body.toString();
|
|
673
|
+
return { state: text.length > maxCaptureText ? 'truncated' : 'complete', text: trimText(text), encoding: 'utf8' };
|
|
674
|
+
}
|
|
675
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
676
|
+
const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
677
|
+
return { ...encodeCapturedBytes(bytes.subarray(0, maxCaptureText)), state: bytes.length > maxCaptureText ? 'truncated' : 'complete' };
|
|
678
|
+
}
|
|
679
|
+
return { state: 'unsupported-body-type' };
|
|
680
|
+
}
|
|
681
|
+
const Request = globalValue('Request');
|
|
682
|
+
if (typeof Request === 'function' && input instanceof Request) return captureResponseBody(input);
|
|
683
|
+
return { state: 'empty', text: '', encoding: 'utf8' };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function encodeCapturedBytes(bytes) {
|
|
687
|
+
// Preserve arbitrary protocol bytes. Encoding is explicit so consumers never
|
|
688
|
+
// mistake a printable Base64 value for the App's original text body.
|
|
689
|
+
let text;
|
|
690
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {}
|
|
691
|
+
if (text !== undefined && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
|
|
692
|
+
return { text, encoding: 'utf8' };
|
|
693
|
+
return { text: btoa(String.fromCharCode(...bytes)), encoding: 'base64' };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function captureResponseBody(response) {
|
|
697
|
+
let reader, timer;
|
|
698
|
+
try {
|
|
699
|
+
const copy = response.clone();
|
|
700
|
+
if (copy.body === null) return { state: 'empty', text: '', encoding: 'utf8' };
|
|
701
|
+
reader = copy.body.getReader();
|
|
702
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('capture-body-timeout')), 1000); });
|
|
703
|
+
const chunks = []; let length = 0;
|
|
704
|
+
while (true) {
|
|
705
|
+
const next = await Promise.race([reader.read(), timeout]);
|
|
706
|
+
if (next.done) {
|
|
707
|
+
const bytes = new Uint8Array(length); let offset = 0;
|
|
708
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
709
|
+
return { state: 'complete', ...encodeCapturedBytes(bytes) };
|
|
710
|
+
}
|
|
711
|
+
if (length + next.value.length > maxCaptureText) return { state: 'too-large' };
|
|
712
|
+
chunks.push(next.value); length += next.value.length;
|
|
713
|
+
}
|
|
714
|
+
} catch (error) { return { state: error.message === 'capture-body-timeout' ? 'timeout' : 'unavailable' }; }
|
|
715
|
+
finally { clearTimeout(timer); if (reader) void reader.cancel().catch(() => {}); }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function installXhrCapture(state, recordNetwork, options) {
|
|
475
719
|
const win = globalValue('window');
|
|
476
720
|
if (!win || !win.XMLHttpRequest) return;
|
|
477
721
|
const Xhr = win.XMLHttpRequest;
|
|
@@ -484,18 +728,29 @@
|
|
|
484
728
|
Xhr.prototype.send = function capturedSend(body) {
|
|
485
729
|
const meta = this.__aiAppBridge || {};
|
|
486
730
|
const startedAtMs = Date.now();
|
|
487
|
-
|
|
488
|
-
|
|
731
|
+
const context = captureContext(state);
|
|
732
|
+
state.captures.network.pending++;
|
|
733
|
+
const requestBody = captureRequestBody(null, { body }, options.captureRequestBodies === true);
|
|
734
|
+
const onEnd = () => {
|
|
735
|
+
const response = options.captureResponseBodies !== true ? { state: 'disabled' }
|
|
736
|
+
: this.responseType !== '' && this.responseType !== 'text' ? { state: 'unsupported-body-type' }
|
|
737
|
+
: { text: trimText(this.responseText), state: this.responseText.length > maxCaptureText ? 'truncated' : 'complete', encoding: 'utf8' };
|
|
738
|
+
const record = {
|
|
489
739
|
source: 'xhr-auto',
|
|
490
740
|
method: meta.method || 'GET',
|
|
491
741
|
url: meta.url || '',
|
|
492
742
|
statusCode: this.status,
|
|
493
743
|
durationMs: Date.now() - startedAtMs,
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
744
|
+
responseBody: response.text, responseBodyState: response.state,
|
|
745
|
+
responseBodyEncoding: response.encoding,
|
|
746
|
+
};
|
|
747
|
+
void requestBody.then(request => {
|
|
748
|
+
recordNetwork({ ...record, requestBody: request.text, requestBodyState: request.state, requestBodyEncoding: request.encoding }, context);
|
|
749
|
+
}).finally(() => { state.captures.network.pending--; });
|
|
750
|
+
};
|
|
751
|
+
this.addEventListener('loadend', onEnd, { once: true });
|
|
752
|
+
try { return originalSend.apply(this, arguments); }
|
|
753
|
+
catch (error) { this.removeEventListener('loadend', onEnd); state.captures.network.pending--; throw error; }
|
|
499
754
|
};
|
|
500
755
|
state.restores.push(() => {
|
|
501
756
|
Xhr.prototype.open = originalOpen;
|
|
@@ -503,6 +758,453 @@
|
|
|
503
758
|
});
|
|
504
759
|
}
|
|
505
760
|
|
|
761
|
+
function installUiCapture(state, recordEvent, captureOption) {
|
|
762
|
+
const options = captureOption === true ? {} : captureOption;
|
|
763
|
+
if (!options || options.enabled === false) return;
|
|
764
|
+
const doc = options.document || globalValue('document');
|
|
765
|
+
if (!doc || typeof doc.addEventListener !== 'function') return;
|
|
766
|
+
const win = options.window || globalValue('window');
|
|
767
|
+
const pending = [];
|
|
768
|
+
const debounceMs = nonNegativeNumber(options.debounceMs, 80);
|
|
769
|
+
const maxBatchSize = positiveInteger(options.maxBatchSize, 50);
|
|
770
|
+
const maxPendingEvents = positiveInteger(options.maxPendingEvents, 200);
|
|
771
|
+
const fingerprintSalt = randomFingerprintSalt();
|
|
772
|
+
let fingerprintSequence = 0;
|
|
773
|
+
let timer = null;
|
|
774
|
+
let droppedEvents = 0;
|
|
775
|
+
let lastRoute = routeFromLocation(win && win.location);
|
|
776
|
+
let domFingerprint = fingerprintDom(doc, win, options, fingerprintSalt);
|
|
777
|
+
domFingerprint.ref = `ui-fp-${++fingerprintSequence}`;
|
|
778
|
+
const dialogStates = new Map();
|
|
779
|
+
for (const dialog of queryElements(doc, dialogLikeSelector)) {
|
|
780
|
+
dialogStates.set(dialog, isDialogActive(dialog));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const scheduleFlush = () => {
|
|
784
|
+
if (timer === null) timer = setTimeout(flush, debounceMs);
|
|
785
|
+
};
|
|
786
|
+
const flush = (drain = false) => {
|
|
787
|
+
if (timer !== null) {
|
|
788
|
+
clearTimeout(timer);
|
|
789
|
+
timer = null;
|
|
790
|
+
}
|
|
791
|
+
if (!pending.length) return;
|
|
792
|
+
do {
|
|
793
|
+
const origin = pending[0];
|
|
794
|
+
const changedOrigin = pending.findIndex(event => event.actionId !== origin.actionId || event.association !== origin.association);
|
|
795
|
+
const count = changedOrigin < 0 ? maxBatchSize : Math.min(maxBatchSize, changedOrigin);
|
|
796
|
+
const events = pending.splice(0, count);
|
|
797
|
+
state.captures.events.pending = pending.length;
|
|
798
|
+
const dropped = droppedEvents;
|
|
799
|
+
droppedEvents = 0;
|
|
800
|
+
const signals = uiBatchSignals(events);
|
|
801
|
+
recordEvent('ui', 'batch', {
|
|
802
|
+
batchSize: events.length,
|
|
803
|
+
droppedEvents: dropped,
|
|
804
|
+
...signals,
|
|
805
|
+
events,
|
|
806
|
+
}, { actionId: origin.actionId, association: origin.association });
|
|
807
|
+
} while (drain && pending.length);
|
|
808
|
+
if (pending.length) scheduleFlush();
|
|
809
|
+
};
|
|
810
|
+
const enqueue = (event) => {
|
|
811
|
+
if (!state.started) return;
|
|
812
|
+
if (pending.length >= maxPendingEvents) {
|
|
813
|
+
pending.shift();
|
|
814
|
+
droppedEvents += 1;
|
|
815
|
+
state.captures.events.losses++;
|
|
816
|
+
}
|
|
817
|
+
pending.push({
|
|
818
|
+
timestampMs: Date.now(),
|
|
819
|
+
route: routeFromLocation(win && win.location),
|
|
820
|
+
...event,
|
|
821
|
+
...actionContext(state),
|
|
822
|
+
});
|
|
823
|
+
state.captures.events.pending = pending.length;
|
|
824
|
+
scheduleFlush();
|
|
825
|
+
};
|
|
826
|
+
state.flushUi = () => flush(true);
|
|
827
|
+
state.restores.push(() => { state.flushUi = null; });
|
|
828
|
+
const onClick = (event) => {
|
|
829
|
+
enqueue({
|
|
830
|
+
type: 'interaction.click',
|
|
831
|
+
target: uiTarget(event && event.target),
|
|
832
|
+
});
|
|
833
|
+
};
|
|
834
|
+
const enqueueValueEvent = (type, event) => {
|
|
835
|
+
const target = event && event.target;
|
|
836
|
+
const value = text(target && target.value);
|
|
837
|
+
const sensitive = isSensitiveInput(target);
|
|
838
|
+
const item = {
|
|
839
|
+
type,
|
|
840
|
+
target: uiTarget(target),
|
|
841
|
+
changed: true,
|
|
842
|
+
length: value.length,
|
|
843
|
+
sensitive,
|
|
844
|
+
};
|
|
845
|
+
if (!sensitive) item.value = trimText(value, 300);
|
|
846
|
+
enqueue(item);
|
|
847
|
+
};
|
|
848
|
+
const onInput = (event) => enqueueValueEvent('interaction.input', event);
|
|
849
|
+
const onChange = (event) => enqueueValueEvent('interaction.change', event);
|
|
850
|
+
const onFocus = (event) => {
|
|
851
|
+
enqueue({
|
|
852
|
+
type: 'interaction.focus',
|
|
853
|
+
target: uiTarget(event && event.target),
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
const onRoute = (navigationType) => {
|
|
857
|
+
const nextRoute = routeFromLocation(win && win.location);
|
|
858
|
+
if (nextRoute === lastRoute) return;
|
|
859
|
+
const previousRoute = lastRoute;
|
|
860
|
+
lastRoute = nextRoute;
|
|
861
|
+
enqueue({
|
|
862
|
+
type: 'route.change',
|
|
863
|
+
navigationType,
|
|
864
|
+
fromRoute: previousRoute,
|
|
865
|
+
toRoute: nextRoute,
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
const onPopState = () => onRoute('popstate');
|
|
869
|
+
const onHashChange = () => onRoute('hashchange');
|
|
870
|
+
const onMutations = (records) => {
|
|
871
|
+
if (!state.started) return;
|
|
872
|
+
const previousFingerprint = domFingerprint;
|
|
873
|
+
const nextFingerprint = fingerprintDom(doc, win, options, fingerprintSalt);
|
|
874
|
+
const fingerprintChanged = previousFingerprint.hash !== nextFingerprint.hash;
|
|
875
|
+
nextFingerprint.ref = fingerprintChanged
|
|
876
|
+
? `ui-fp-${++fingerprintSequence}`
|
|
877
|
+
: previousFingerprint.ref;
|
|
878
|
+
domFingerprint = nextFingerprint;
|
|
879
|
+
const mutations = summarizeMutations(records);
|
|
880
|
+
const dialogTransitions = detectDialogTransitions(records, dialogStates);
|
|
881
|
+
enqueue({
|
|
882
|
+
type: 'dom.mutation',
|
|
883
|
+
mutations,
|
|
884
|
+
fingerprint: {
|
|
885
|
+
// Runtime-local opaque references preserve correlation without
|
|
886
|
+
// publishing a content-derived hash that can be dictionary-enumerated.
|
|
887
|
+
beforeHash: previousFingerprint.ref,
|
|
888
|
+
afterHash: nextFingerprint.ref,
|
|
889
|
+
changed: fingerprintChanged,
|
|
890
|
+
},
|
|
891
|
+
diff: {
|
|
892
|
+
elementCount: nextFingerprint.elementCount - previousFingerprint.elementCount,
|
|
893
|
+
controlCount: nextFingerprint.controlCount - previousFingerprint.controlCount,
|
|
894
|
+
dialogCount: nextFingerprint.dialogCount - previousFingerprint.dialogCount,
|
|
895
|
+
activeDialogCount: nextFingerprint.activeDialogCount - previousFingerprint.activeDialogCount,
|
|
896
|
+
bodyTextLength: nextFingerprint.bodyTextLength - previousFingerprint.bodyTextLength,
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
for (const transition of dialogTransitions) {
|
|
900
|
+
enqueue({
|
|
901
|
+
type: transition.type,
|
|
902
|
+
dialog: uiTarget(transition.dialog),
|
|
903
|
+
fingerprint: nextFingerprint.ref,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
const historyObject = win && win.history;
|
|
908
|
+
const originalPushState = historyObject && historyObject.pushState;
|
|
909
|
+
const originalReplaceState = historyObject && historyObject.replaceState;
|
|
910
|
+
let capturedPushState;
|
|
911
|
+
let capturedReplaceState;
|
|
912
|
+
if (historyObject && typeof originalPushState === 'function') {
|
|
913
|
+
capturedPushState = function uiCapturedPushState(...args) {
|
|
914
|
+
const result = originalPushState.apply(this, args);
|
|
915
|
+
onRoute('pushState');
|
|
916
|
+
return result;
|
|
917
|
+
};
|
|
918
|
+
historyObject.pushState = capturedPushState;
|
|
919
|
+
}
|
|
920
|
+
if (historyObject && typeof originalReplaceState === 'function') {
|
|
921
|
+
capturedReplaceState = function uiCapturedReplaceState(...args) {
|
|
922
|
+
const result = originalReplaceState.apply(this, args);
|
|
923
|
+
onRoute('replaceState');
|
|
924
|
+
return result;
|
|
925
|
+
};
|
|
926
|
+
historyObject.replaceState = capturedReplaceState;
|
|
927
|
+
}
|
|
928
|
+
const MutationObserverCtor = options.MutationObserver
|
|
929
|
+
|| (win && win.MutationObserver)
|
|
930
|
+
|| globalValue('MutationObserver');
|
|
931
|
+
let mutationObserver = null;
|
|
932
|
+
if (typeof MutationObserverCtor === 'function' && (doc.documentElement || doc.body)) {
|
|
933
|
+
mutationObserver = new MutationObserverCtor(onMutations);
|
|
934
|
+
mutationObserver.observe(doc.documentElement || doc.body, {
|
|
935
|
+
subtree: true,
|
|
936
|
+
childList: true,
|
|
937
|
+
attributes: true,
|
|
938
|
+
characterData: true,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
doc.addEventListener('click', onClick, true);
|
|
943
|
+
doc.addEventListener('input', onInput, true);
|
|
944
|
+
doc.addEventListener('change', onChange, true);
|
|
945
|
+
doc.addEventListener('focus', onFocus, true);
|
|
946
|
+
if (win && typeof win.addEventListener === 'function') {
|
|
947
|
+
win.addEventListener('popstate', onPopState);
|
|
948
|
+
win.addEventListener('hashchange', onHashChange);
|
|
949
|
+
}
|
|
950
|
+
state.restores.push(() => {
|
|
951
|
+
doc.removeEventListener('click', onClick, true);
|
|
952
|
+
doc.removeEventListener('input', onInput, true);
|
|
953
|
+
doc.removeEventListener('change', onChange, true);
|
|
954
|
+
doc.removeEventListener('focus', onFocus, true);
|
|
955
|
+
if (win && typeof win.removeEventListener === 'function') {
|
|
956
|
+
win.removeEventListener('popstate', onPopState);
|
|
957
|
+
win.removeEventListener('hashchange', onHashChange);
|
|
958
|
+
}
|
|
959
|
+
if (historyObject && historyObject.pushState === capturedPushState) {
|
|
960
|
+
historyObject.pushState = originalPushState;
|
|
961
|
+
}
|
|
962
|
+
if (historyObject && historyObject.replaceState === capturedReplaceState) {
|
|
963
|
+
historyObject.replaceState = originalReplaceState;
|
|
964
|
+
}
|
|
965
|
+
if (mutationObserver) mutationObserver.disconnect();
|
|
966
|
+
flush(true);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function fingerprintDom(doc, win, options, salt) {
|
|
971
|
+
const maxElements = positiveInteger(options.maxFingerprintElements, 200);
|
|
972
|
+
const maxText = positiveInteger(options.maxFingerprintText, 2000);
|
|
973
|
+
const elements = queryElements(doc, '*').slice(0, maxElements);
|
|
974
|
+
const controls = queryElements(doc, defaultControlSelector);
|
|
975
|
+
const dialogs = queryElements(doc, dialogLikeSelector);
|
|
976
|
+
const activeDialogCount = dialogs.filter(isDialogActive).length;
|
|
977
|
+
const bodyTextValue = text(doc && doc.body && doc.body.innerText);
|
|
978
|
+
const structure = elements.map((element) => [
|
|
979
|
+
text(element && element.tagName).toLowerCase(),
|
|
980
|
+
text(element && element.id),
|
|
981
|
+
attr(element, 'role'),
|
|
982
|
+
attr(element, 'aria-label'),
|
|
983
|
+
attr(element, 'class'),
|
|
984
|
+
trimText(attr(element, 'style'), 300),
|
|
985
|
+
attr(element, 'aria-hidden'),
|
|
986
|
+
element && element.hidden ? 'hidden' : '',
|
|
987
|
+
element && element.open ? 'open' : '',
|
|
988
|
+
].join(':')).join('|');
|
|
989
|
+
const material = [
|
|
990
|
+
text(doc && doc.title),
|
|
991
|
+
routeFromLocation(win && win.location),
|
|
992
|
+
bodyTextValue.slice(0, maxText),
|
|
993
|
+
structure,
|
|
994
|
+
elements.length,
|
|
995
|
+
controls.length,
|
|
996
|
+
dialogs.length,
|
|
997
|
+
activeDialogCount,
|
|
998
|
+
].join('\n');
|
|
999
|
+
return {
|
|
1000
|
+
hash: hashText(material, salt),
|
|
1001
|
+
elementCount: elements.length,
|
|
1002
|
+
controlCount: controls.length,
|
|
1003
|
+
dialogCount: dialogs.length,
|
|
1004
|
+
activeDialogCount,
|
|
1005
|
+
bodyTextLength: bodyTextValue.length,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function queryElements(doc, selector) {
|
|
1010
|
+
if (!doc || typeof doc.querySelectorAll !== 'function') return [];
|
|
1011
|
+
try {
|
|
1012
|
+
return Array.prototype.slice.call(doc.querySelectorAll(selector));
|
|
1013
|
+
} catch (_) {
|
|
1014
|
+
return [];
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function summarizeMutations(records) {
|
|
1019
|
+
const attributeNames = new Set();
|
|
1020
|
+
const summary = {
|
|
1021
|
+
records: 0,
|
|
1022
|
+
addedNodes: 0,
|
|
1023
|
+
removedNodes: 0,
|
|
1024
|
+
attributeChanges: 0,
|
|
1025
|
+
textChanges: 0,
|
|
1026
|
+
};
|
|
1027
|
+
for (const record of Array.prototype.slice.call(records || [])) {
|
|
1028
|
+
summary.records += 1;
|
|
1029
|
+
if (record.type === 'attributes') {
|
|
1030
|
+
summary.attributeChanges += 1;
|
|
1031
|
+
if (record.attributeName) attributeNames.add(trimText(record.attributeName, 80));
|
|
1032
|
+
}
|
|
1033
|
+
if (record.type === 'characterData') summary.textChanges += 1;
|
|
1034
|
+
const added = Array.prototype.slice.call(record.addedNodes || []);
|
|
1035
|
+
const removed = Array.prototype.slice.call(record.removedNodes || []);
|
|
1036
|
+
summary.addedNodes += added.length;
|
|
1037
|
+
summary.removedNodes += removed.length;
|
|
1038
|
+
}
|
|
1039
|
+
const allAttributeNames = Array.from(attributeNames).sort();
|
|
1040
|
+
summary.attributeNames = allAttributeNames.slice(0, 20);
|
|
1041
|
+
summary.attributeNamesTruncated = Math.max(0, allAttributeNames.length - summary.attributeNames.length);
|
|
1042
|
+
return summary;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function uiBatchSignals(events) {
|
|
1046
|
+
let semanticChanged = false;
|
|
1047
|
+
let renderChanged = false;
|
|
1048
|
+
let interactionObserved = false;
|
|
1049
|
+
for (const event of events) {
|
|
1050
|
+
const type = text(event && event.type).toLowerCase();
|
|
1051
|
+
if (type.startsWith('interaction.')) interactionObserved = true;
|
|
1052
|
+
if (type === 'route.change') semanticChanged = true;
|
|
1053
|
+
if (type.startsWith('dialog.')) {
|
|
1054
|
+
semanticChanged = true;
|
|
1055
|
+
renderChanged = true;
|
|
1056
|
+
}
|
|
1057
|
+
if (type === 'dom.mutation') {
|
|
1058
|
+
const mutations = event.mutations || {};
|
|
1059
|
+
const attributes = Array.isArray(mutations.attributeNames)
|
|
1060
|
+
? mutations.attributeNames.map((name) => text(name).toLowerCase())
|
|
1061
|
+
: [];
|
|
1062
|
+
const structural = Number(mutations.addedNodes || 0) > 0
|
|
1063
|
+
|| Number(mutations.removedNodes || 0) > 0
|
|
1064
|
+
|| Number(mutations.textChanges || 0) > 0;
|
|
1065
|
+
const hasSemanticAttribute = attributes.some((name) => !['style', 'class'].includes(name));
|
|
1066
|
+
if (structural || hasSemanticAttribute) semanticChanged = true;
|
|
1067
|
+
if (event.fingerprint?.changed || Number(mutations.attributeChanges || 0) > 0 || structural) {
|
|
1068
|
+
renderChanged = true;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return { semanticChanged, renderChanged, interactionObserved };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function dialogLikeNodes(nodes) {
|
|
1076
|
+
const found = [];
|
|
1077
|
+
const seen = new Set();
|
|
1078
|
+
const visit = (node) => {
|
|
1079
|
+
if (!node || seen.has(node)) return;
|
|
1080
|
+
seen.add(node);
|
|
1081
|
+
if (isDialogLike(node)) found.push(node);
|
|
1082
|
+
if (typeof node.querySelectorAll === 'function') {
|
|
1083
|
+
for (const child of queryElements(node, dialogLikeSelector)) visit(child);
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
for (const node of nodes || []) visit(node);
|
|
1087
|
+
return found;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function detectDialogTransitions(records, dialogStates) {
|
|
1091
|
+
const transitions = [];
|
|
1092
|
+
for (const record of Array.prototype.slice.call(records || [])) {
|
|
1093
|
+
if (record.type === 'childList') {
|
|
1094
|
+
for (const dialog of dialogLikeNodes(Array.prototype.slice.call(record.removedNodes || []))) {
|
|
1095
|
+
const wasActive = dialogStates.has(dialog)
|
|
1096
|
+
? dialogStates.get(dialog)
|
|
1097
|
+
: isDialogActive(dialog);
|
|
1098
|
+
if (wasActive) transitions.push({ type: 'dialog.close', dialog });
|
|
1099
|
+
dialogStates.delete(dialog);
|
|
1100
|
+
}
|
|
1101
|
+
for (const dialog of dialogLikeNodes(Array.prototype.slice.call(record.addedNodes || []))) {
|
|
1102
|
+
const active = isDialogActive(dialog);
|
|
1103
|
+
if (active) transitions.push({ type: 'dialog.open', dialog });
|
|
1104
|
+
dialogStates.set(dialog, active);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (record.type === 'attributes') {
|
|
1108
|
+
const dialog = record.target;
|
|
1109
|
+
const dialogLike = isDialogLike(dialog);
|
|
1110
|
+
if (!dialogLike && !dialogStates.has(dialog)) continue;
|
|
1111
|
+
const wasActive = dialogStates.has(dialog) ? dialogStates.get(dialog) : false;
|
|
1112
|
+
const active = dialogLike && isDialogActive(dialog);
|
|
1113
|
+
if (active !== wasActive) {
|
|
1114
|
+
transitions.push({ type: active ? 'dialog.open' : 'dialog.close', dialog });
|
|
1115
|
+
}
|
|
1116
|
+
if (dialogLike) dialogStates.set(dialog, active);
|
|
1117
|
+
else dialogStates.delete(dialog);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return transitions;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function isDialogLike(element) {
|
|
1124
|
+
const tag = text(element && element.tagName).toLowerCase();
|
|
1125
|
+
const role = attr(element, 'role').toLowerCase();
|
|
1126
|
+
const ariaModal = attr(element, 'aria-modal').toLowerCase();
|
|
1127
|
+
const className = attr(element, 'class').toLowerCase();
|
|
1128
|
+
return tag === 'dialog'
|
|
1129
|
+
|| role === 'dialog'
|
|
1130
|
+
|| role === 'alertdialog'
|
|
1131
|
+
|| ariaModal === 'true'
|
|
1132
|
+
|| /(^|\s)(modal|dialog)(\s|$)/.test(className);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function isDialogActive(element) {
|
|
1136
|
+
if (!element || element.hidden || attr(element, 'aria-hidden').toLowerCase() === 'true') return false;
|
|
1137
|
+
const style = attr(element, 'style').toLowerCase();
|
|
1138
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/.test(style)) return false;
|
|
1139
|
+
if (text(element.tagName).toLowerCase() === 'dialog' && typeof element.open === 'boolean') {
|
|
1140
|
+
return element.open;
|
|
1141
|
+
}
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function hashText(value, salt = '') {
|
|
1146
|
+
const raw = `${salt}\u0000${text(value)}`;
|
|
1147
|
+
let first = 2166136261;
|
|
1148
|
+
let second = 2246822507;
|
|
1149
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
1150
|
+
const code = raw.charCodeAt(index);
|
|
1151
|
+
first ^= code;
|
|
1152
|
+
first = Math.imul(first, 16777619);
|
|
1153
|
+
second ^= code;
|
|
1154
|
+
second = Math.imul(second, 3266489909);
|
|
1155
|
+
}
|
|
1156
|
+
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function randomFingerprintSalt() {
|
|
1160
|
+
const cryptoObject = globalValue('crypto');
|
|
1161
|
+
if (cryptoObject && typeof cryptoObject.getRandomValues === 'function') {
|
|
1162
|
+
const values = new Uint32Array(4);
|
|
1163
|
+
cryptoObject.getRandomValues(values);
|
|
1164
|
+
return Array.from(values, (value) => value.toString(16).padStart(8, '0')).join('');
|
|
1165
|
+
}
|
|
1166
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function positiveInteger(value, fallback) {
|
|
1170
|
+
const number = value === undefined ? fallback : Number(value);
|
|
1171
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function nonNegativeNumber(value, fallback) {
|
|
1175
|
+
const number = value === undefined ? fallback : Number(value);
|
|
1176
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function uiTarget(element) {
|
|
1180
|
+
return {
|
|
1181
|
+
tag: text(element && element.tagName).toLowerCase(),
|
|
1182
|
+
id: text(element && element.id),
|
|
1183
|
+
name: attr(element, 'name'),
|
|
1184
|
+
type: attr(element, 'type'),
|
|
1185
|
+
role: attr(element, 'role'),
|
|
1186
|
+
ariaLabel: attr(element, 'aria-label'),
|
|
1187
|
+
text: trimText(element && (element.innerText || element.title || attr(element, 'aria-label')), 160),
|
|
1188
|
+
disabled: Boolean(element && element.disabled),
|
|
1189
|
+
bounds: bounds(element),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function isSensitiveInput(element) {
|
|
1194
|
+
const values = [
|
|
1195
|
+
attr(element, 'type'),
|
|
1196
|
+
attr(element, 'autocomplete'),
|
|
1197
|
+
attr(element, 'name'),
|
|
1198
|
+
text(element && element.id),
|
|
1199
|
+
].join(' ').toLowerCase();
|
|
1200
|
+
return /password|passwd|pwd|passcode|current-password|new-password/.test(values);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function routeFromLocation(location) {
|
|
1204
|
+
if (!location) return '';
|
|
1205
|
+
return `${location.pathname || ''}${location.search || ''}${location.hash || ''}`;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
506
1208
|
function withToken(endpoint, token) {
|
|
507
1209
|
if (!token) return endpoint;
|
|
508
1210
|
const base = locationHref() || 'http://127.0.0.1';
|
|
@@ -513,20 +1215,14 @@
|
|
|
513
1215
|
|
|
514
1216
|
function storedSessionId(key) {
|
|
515
1217
|
const storage = globalValue('sessionStorage');
|
|
516
|
-
|
|
1218
|
+
if (!storage) throw new Error('web_session_storage_unavailable: supply sessionId');
|
|
1219
|
+
const existing = storage.getItem(key);
|
|
517
1220
|
if (existing) return existing;
|
|
518
|
-
const next =
|
|
519
|
-
|
|
1221
|
+
const next = newIdentity();
|
|
1222
|
+
storage.setItem(key, next);
|
|
520
1223
|
return next;
|
|
521
1224
|
}
|
|
522
1225
|
|
|
523
|
-
function dispatchInputEvents(element) {
|
|
524
|
-
const win = globalValue('window');
|
|
525
|
-
if (!win || typeof win.Event !== 'function') return;
|
|
526
|
-
element.dispatchEvent(new win.Event('input', { bubbles: true }));
|
|
527
|
-
element.dispatchEvent(new win.Event('change', { bubbles: true }));
|
|
528
|
-
}
|
|
529
|
-
|
|
530
1226
|
function bounds(element) {
|
|
531
1227
|
if (!element || !element.getBoundingClientRect) {
|
|
532
1228
|
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|