@flrande/bak-extension 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/background.global.js +623 -0
- package/dist/content.global.js +2042 -0
- package/dist/manifest.json +20 -0
- package/dist/popup.global.js +52 -0
- package/dist/popup.html +106 -0
- package/package.json +18 -0
- package/public/manifest.json +20 -0
- package/public/popup.html +106 -0
- package/scripts/copy-assets.mjs +16 -0
- package/src/background.ts +705 -0
- package/src/content.ts +2267 -0
- package/src/limitations.ts +38 -0
- package/src/popup.ts +65 -0
- package/src/privacy.ts +135 -0
- package/src/reconnect.ts +25 -0
- package/src/url-policy.ts +12 -0
- package/tsconfig.json +9 -0
package/src/content.ts
ADDED
|
@@ -0,0 +1,2267 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessibilityNode,
|
|
3
|
+
ConsoleEntry,
|
|
4
|
+
ElementMapItem,
|
|
5
|
+
Locator,
|
|
6
|
+
NetworkEntry,
|
|
7
|
+
PageDomSummary,
|
|
8
|
+
PageMetrics,
|
|
9
|
+
PageTextChunk
|
|
10
|
+
} from '@flrande/bak-protocol';
|
|
11
|
+
import { inferSafeName, redactElementText, type RedactTextOptions } from './privacy.js';
|
|
12
|
+
import { unsupportedLocatorHint } from './limitations.js';
|
|
13
|
+
|
|
14
|
+
type ActionName =
|
|
15
|
+
| 'click'
|
|
16
|
+
| 'type'
|
|
17
|
+
| 'scroll'
|
|
18
|
+
| 'hover'
|
|
19
|
+
| 'doubleClick'
|
|
20
|
+
| 'rightClick'
|
|
21
|
+
| 'dragDrop'
|
|
22
|
+
| 'select'
|
|
23
|
+
| 'check'
|
|
24
|
+
| 'uncheck'
|
|
25
|
+
| 'scrollIntoView'
|
|
26
|
+
| 'focus'
|
|
27
|
+
| 'blur';
|
|
28
|
+
|
|
29
|
+
interface ActionMessage {
|
|
30
|
+
type: 'bak.performAction';
|
|
31
|
+
action: ActionName;
|
|
32
|
+
locator?: Locator;
|
|
33
|
+
from?: Locator;
|
|
34
|
+
to?: Locator;
|
|
35
|
+
text?: string;
|
|
36
|
+
clear?: boolean;
|
|
37
|
+
requiresConfirm?: boolean;
|
|
38
|
+
dx?: number;
|
|
39
|
+
dy?: number;
|
|
40
|
+
values?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface WaitMessage {
|
|
44
|
+
type: 'bak.waitFor';
|
|
45
|
+
mode: 'selector' | 'text' | 'url';
|
|
46
|
+
value: string;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CandidateMessage {
|
|
51
|
+
type: 'bak.selectCandidate';
|
|
52
|
+
candidates: ElementMapItem[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CollectElementsMessage {
|
|
56
|
+
type: 'bak.collectElements';
|
|
57
|
+
debugRichText?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RpcMessage {
|
|
61
|
+
type: 'bak.rpc';
|
|
62
|
+
method: string;
|
|
63
|
+
params?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ActionError {
|
|
67
|
+
code: string;
|
|
68
|
+
message: string;
|
|
69
|
+
data?: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type ActionResult = { ok: true } | { ok: false; error: ActionError };
|
|
73
|
+
type ActionAssessment = { ok: true; point: { x: number; y: number } } | { ok: false; error: ActionError };
|
|
74
|
+
type RpcEnvelope = { ok: true; result: unknown } | { ok: false; error: ActionError };
|
|
75
|
+
|
|
76
|
+
const consoleEntries: ConsoleEntry[] = [];
|
|
77
|
+
const networkEntries: NetworkEntry[] = [];
|
|
78
|
+
const elementCache = new Map<string, HTMLElement>();
|
|
79
|
+
const contextState = {
|
|
80
|
+
framePath: [] as string[],
|
|
81
|
+
shadowPath: [] as string[]
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let networkSequence = 0;
|
|
85
|
+
let longTaskCount = 0;
|
|
86
|
+
let longTaskDurationMs = 0;
|
|
87
|
+
let performanceBaselineMs = 0;
|
|
88
|
+
|
|
89
|
+
function isHtmlElement(node: Element | null): node is HTMLElement {
|
|
90
|
+
if (!node) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const view = node.ownerDocument.defaultView;
|
|
94
|
+
return Boolean(view && node instanceof view.HTMLElement);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isInputElement(element: Element): element is HTMLInputElement {
|
|
98
|
+
const view = element.ownerDocument.defaultView;
|
|
99
|
+
return Boolean(view && element instanceof view.HTMLInputElement);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isTextAreaElement(element: Element): element is HTMLTextAreaElement {
|
|
103
|
+
const view = element.ownerDocument.defaultView;
|
|
104
|
+
return Boolean(view && element instanceof view.HTMLTextAreaElement);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isFrameElement(element: Element): element is HTMLIFrameElement | HTMLFrameElement {
|
|
108
|
+
const view = element.ownerDocument.defaultView as
|
|
109
|
+
| (Window & typeof globalThis & { HTMLFrameElement?: typeof HTMLFrameElement })
|
|
110
|
+
| null;
|
|
111
|
+
if (!view) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const iframeMatch = element instanceof view.HTMLIFrameElement;
|
|
115
|
+
const frameCtor = view.HTMLFrameElement;
|
|
116
|
+
const frameMatch = typeof frameCtor === 'function' ? element instanceof frameCtor : false;
|
|
117
|
+
return iframeMatch || frameMatch;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pushConsole(level: ConsoleEntry['level'], message: string, source?: string): void {
|
|
121
|
+
consoleEntries.push({
|
|
122
|
+
level,
|
|
123
|
+
message,
|
|
124
|
+
source,
|
|
125
|
+
ts: Date.now()
|
|
126
|
+
});
|
|
127
|
+
if (consoleEntries.length > 1000) {
|
|
128
|
+
consoleEntries.shift();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function patchConsoleCapture(): void {
|
|
133
|
+
const methods: Array<{ method: 'log' | 'debug' | 'info' | 'warn' | 'error'; level: ConsoleEntry['level'] }> = [
|
|
134
|
+
{ method: 'log', level: 'log' },
|
|
135
|
+
{ method: 'debug', level: 'debug' },
|
|
136
|
+
{ method: 'info', level: 'info' },
|
|
137
|
+
{ method: 'warn', level: 'warn' },
|
|
138
|
+
{ method: 'error', level: 'error' }
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
for (const entry of methods) {
|
|
142
|
+
const original = console[entry.method] as (...args: unknown[]) => void;
|
|
143
|
+
(console as unknown as Record<string, (...args: unknown[]) => void>)[entry.method] = (...args: unknown[]) => {
|
|
144
|
+
const message = args
|
|
145
|
+
.map((item) => {
|
|
146
|
+
if (item instanceof Error) {
|
|
147
|
+
return item.message;
|
|
148
|
+
}
|
|
149
|
+
if (typeof item === 'string') {
|
|
150
|
+
return item;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return JSON.stringify(item);
|
|
154
|
+
} catch {
|
|
155
|
+
return String(item);
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.join(' ');
|
|
159
|
+
pushConsole(entry.level, message, 'isolated');
|
|
160
|
+
original.apply(console, args);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
window.addEventListener('bak:console', (event: Event) => {
|
|
165
|
+
const detail = (event as CustomEvent<{ level?: ConsoleEntry['level']; message?: string; source?: string; ts?: number }>).detail;
|
|
166
|
+
if (!detail || typeof detail !== 'object') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const level: ConsoleEntry['level'] =
|
|
170
|
+
detail.level === 'debug' || detail.level === 'info' || detail.level === 'warn' || detail.level === 'error' ? detail.level : 'log';
|
|
171
|
+
const message = typeof detail.message === 'string' ? detail.message : '';
|
|
172
|
+
if (!message) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
pushConsole(level, message, detail.source ?? 'page');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const injector = document.createElement('script');
|
|
180
|
+
injector.textContent = `
|
|
181
|
+
(() => {
|
|
182
|
+
const g = window;
|
|
183
|
+
if (g.__bakPageConsolePatched) return;
|
|
184
|
+
g.__bakPageConsolePatched = true;
|
|
185
|
+
const emit = (level, message, source) =>
|
|
186
|
+
window.dispatchEvent(new CustomEvent('bak:console', { detail: { level, message, source, ts: Date.now() } }));
|
|
187
|
+
const serialize = (value) => {
|
|
188
|
+
if (value instanceof Error) return value.message;
|
|
189
|
+
if (typeof value === 'string') return value;
|
|
190
|
+
try {
|
|
191
|
+
return JSON.stringify(value);
|
|
192
|
+
} catch {
|
|
193
|
+
return String(value);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
['log', 'debug', 'info', 'warn', 'error'].forEach((method) => {
|
|
197
|
+
const original = console[method];
|
|
198
|
+
console[method] = (...args) => {
|
|
199
|
+
emit(method, args.map(serialize).join(' '), 'page');
|
|
200
|
+
return original.apply(console, args);
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
window.addEventListener('error', (event) => {
|
|
204
|
+
emit('error', event.message || 'error event', event.filename || 'page');
|
|
205
|
+
});
|
|
206
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
207
|
+
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
|
208
|
+
emit('error', 'unhandledrejection: ' + reason, 'page');
|
|
209
|
+
});
|
|
210
|
+
})();
|
|
211
|
+
`;
|
|
212
|
+
(document.documentElement ?? document.head ?? document.body).appendChild(injector);
|
|
213
|
+
injector.remove();
|
|
214
|
+
} catch {
|
|
215
|
+
// Keep isolated-world capture if page-world injection is blocked.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
window.addEventListener('error', (event) => {
|
|
219
|
+
pushConsole('error', event.message, event.filename);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
223
|
+
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
|
224
|
+
pushConsole('error', `unhandledrejection: ${reason}`);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pushNetwork(entry: NetworkEntry): void {
|
|
229
|
+
networkEntries.push(entry);
|
|
230
|
+
if (networkEntries.length > 1000) {
|
|
231
|
+
networkEntries.shift();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function patchNetworkCapture(): void {
|
|
236
|
+
window.addEventListener('bak:network', (event: Event) => {
|
|
237
|
+
const detail = (event as CustomEvent<NetworkEntry>).detail;
|
|
238
|
+
if (!detail || typeof detail !== 'object') {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
pushNetwork({
|
|
242
|
+
id: typeof detail.id === 'string' ? detail.id : `net_${Date.now()}_${networkSequence++}`,
|
|
243
|
+
kind: detail.kind === 'xhr' ? 'xhr' : 'fetch',
|
|
244
|
+
method: typeof detail.method === 'string' ? detail.method : 'GET',
|
|
245
|
+
url: typeof detail.url === 'string' ? detail.url : window.location.href,
|
|
246
|
+
status: typeof detail.status === 'number' ? detail.status : 0,
|
|
247
|
+
ok: detail.ok === true,
|
|
248
|
+
ts: typeof detail.ts === 'number' ? detail.ts : Date.now(),
|
|
249
|
+
durationMs: typeof detail.durationMs === 'number' ? detail.durationMs : 0,
|
|
250
|
+
requestBytes: typeof detail.requestBytes === 'number' ? detail.requestBytes : undefined,
|
|
251
|
+
responseBytes: typeof detail.responseBytes === 'number' ? detail.responseBytes : undefined
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const injector = document.createElement('script');
|
|
257
|
+
injector.textContent = `
|
|
258
|
+
(() => {
|
|
259
|
+
const g = window;
|
|
260
|
+
if (g.__bakPageNetworkPatched) return;
|
|
261
|
+
g.__bakPageNetworkPatched = true;
|
|
262
|
+
let seq = 0;
|
|
263
|
+
const emit = (entry) => window.dispatchEvent(new CustomEvent('bak:network', { detail: entry }));
|
|
264
|
+
const nativeFetch = window.fetch.bind(window);
|
|
265
|
+
window.fetch = async (input, init) => {
|
|
266
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
267
|
+
const method = (init && init.method ? init.method : 'GET').toUpperCase();
|
|
268
|
+
const started = performance.now();
|
|
269
|
+
const requestBytes =
|
|
270
|
+
init && typeof init.body === 'string'
|
|
271
|
+
? init.body.length
|
|
272
|
+
: init && init.body instanceof URLSearchParams
|
|
273
|
+
? init.body.toString().length
|
|
274
|
+
: undefined;
|
|
275
|
+
try {
|
|
276
|
+
const response = await nativeFetch(input, init);
|
|
277
|
+
emit({
|
|
278
|
+
id: 'net_' + Date.now() + '_' + seq++,
|
|
279
|
+
kind: 'fetch',
|
|
280
|
+
method,
|
|
281
|
+
url,
|
|
282
|
+
status: response.status,
|
|
283
|
+
ok: response.ok,
|
|
284
|
+
ts: Date.now(),
|
|
285
|
+
durationMs: Math.max(0, performance.now() - started),
|
|
286
|
+
requestBytes,
|
|
287
|
+
responseBytes: Number(response.headers.get('content-length') || '0') || undefined
|
|
288
|
+
});
|
|
289
|
+
return response;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
emit({
|
|
292
|
+
id: 'net_' + Date.now() + '_' + seq++,
|
|
293
|
+
kind: 'fetch',
|
|
294
|
+
method,
|
|
295
|
+
url,
|
|
296
|
+
status: 0,
|
|
297
|
+
ok: false,
|
|
298
|
+
ts: Date.now(),
|
|
299
|
+
durationMs: Math.max(0, performance.now() - started),
|
|
300
|
+
requestBytes
|
|
301
|
+
});
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const xhrOpen = XMLHttpRequest.prototype.open;
|
|
307
|
+
const xhrSend = XMLHttpRequest.prototype.send;
|
|
308
|
+
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
309
|
+
this.__bakMeta = {
|
|
310
|
+
method: String(method || 'GET').toUpperCase(),
|
|
311
|
+
url: typeof url === 'string' ? url : String(url),
|
|
312
|
+
started: performance.now()
|
|
313
|
+
};
|
|
314
|
+
return xhrOpen.call(this, method, url, ...rest);
|
|
315
|
+
};
|
|
316
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
317
|
+
if (typeof body === 'string') {
|
|
318
|
+
this.__bakMeta = { ...(this.__bakMeta || {}), requestBytes: body.length };
|
|
319
|
+
}
|
|
320
|
+
this.addEventListener('loadend', () => {
|
|
321
|
+
const meta = this.__bakMeta || {};
|
|
322
|
+
emit({
|
|
323
|
+
id: 'net_' + Date.now() + '_' + seq++,
|
|
324
|
+
kind: 'xhr',
|
|
325
|
+
method: meta.method || 'GET',
|
|
326
|
+
url: meta.url || window.location.href,
|
|
327
|
+
status: Number(this.status) || 0,
|
|
328
|
+
ok: Number(this.status) >= 200 && Number(this.status) < 400,
|
|
329
|
+
ts: Date.now(),
|
|
330
|
+
durationMs: Math.max(0, performance.now() - (meta.started || performance.now())),
|
|
331
|
+
requestBytes: typeof meta.requestBytes === 'number' ? meta.requestBytes : undefined
|
|
332
|
+
});
|
|
333
|
+
}, { once: true });
|
|
334
|
+
return xhrSend.call(this, body ?? null);
|
|
335
|
+
};
|
|
336
|
+
})();
|
|
337
|
+
`;
|
|
338
|
+
(document.documentElement ?? document.head ?? document.body).appendChild(injector);
|
|
339
|
+
injector.remove();
|
|
340
|
+
} catch {
|
|
341
|
+
// Ignore injection failures and keep isolated-world network patch as fallback.
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const originalFetch = window.fetch.bind(window);
|
|
345
|
+
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
346
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
347
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
348
|
+
const started = performance.now();
|
|
349
|
+
const requestBytes =
|
|
350
|
+
typeof init?.body === 'string'
|
|
351
|
+
? init.body.length
|
|
352
|
+
: init?.body instanceof URLSearchParams
|
|
353
|
+
? init.body.toString().length
|
|
354
|
+
: undefined;
|
|
355
|
+
try {
|
|
356
|
+
const response = await originalFetch(input, init);
|
|
357
|
+
const durationMs = Math.max(0, performance.now() - started);
|
|
358
|
+
pushNetwork({
|
|
359
|
+
id: `net_${Date.now()}_${networkSequence++}`,
|
|
360
|
+
kind: 'fetch',
|
|
361
|
+
method,
|
|
362
|
+
url,
|
|
363
|
+
status: response.status,
|
|
364
|
+
ok: response.ok,
|
|
365
|
+
ts: Date.now(),
|
|
366
|
+
durationMs,
|
|
367
|
+
requestBytes,
|
|
368
|
+
responseBytes: Number(response.headers.get('content-length') ?? '0') || undefined
|
|
369
|
+
});
|
|
370
|
+
return response;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const durationMs = Math.max(0, performance.now() - started);
|
|
373
|
+
pushNetwork({
|
|
374
|
+
id: `net_${Date.now()}_${networkSequence++}`,
|
|
375
|
+
kind: 'fetch',
|
|
376
|
+
method,
|
|
377
|
+
url,
|
|
378
|
+
status: 0,
|
|
379
|
+
ok: false,
|
|
380
|
+
ts: Date.now(),
|
|
381
|
+
durationMs,
|
|
382
|
+
requestBytes
|
|
383
|
+
});
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
}) as typeof window.fetch;
|
|
387
|
+
|
|
388
|
+
const xhrOpen = XMLHttpRequest.prototype.open;
|
|
389
|
+
const xhrSend = XMLHttpRequest.prototype.send;
|
|
390
|
+
|
|
391
|
+
XMLHttpRequest.prototype.open = function open(method: string, url: string | URL, ...rest: unknown[]) {
|
|
392
|
+
const self = this as XMLHttpRequest & {
|
|
393
|
+
__bakMeta?: {
|
|
394
|
+
method: string;
|
|
395
|
+
url: string;
|
|
396
|
+
started: number;
|
|
397
|
+
requestBytes?: number;
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
self.__bakMeta = {
|
|
401
|
+
method: method.toUpperCase(),
|
|
402
|
+
url: typeof url === 'string' ? url : url.toString(),
|
|
403
|
+
started: performance.now()
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return (xhrOpen as (...args: unknown[]) => unknown).call(this, method, url, ...rest);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
XMLHttpRequest.prototype.send = function send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
|
410
|
+
const self = this as XMLHttpRequest & {
|
|
411
|
+
__bakMeta?: {
|
|
412
|
+
method: string;
|
|
413
|
+
url: string;
|
|
414
|
+
started: number;
|
|
415
|
+
requestBytes?: number;
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (typeof body === 'string') {
|
|
420
|
+
const current = self.__bakMeta ?? {
|
|
421
|
+
method: 'GET',
|
|
422
|
+
url: window.location.href,
|
|
423
|
+
started: performance.now()
|
|
424
|
+
};
|
|
425
|
+
self.__bakMeta = {
|
|
426
|
+
...current,
|
|
427
|
+
requestBytes: body.length
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
self.addEventListener(
|
|
432
|
+
'loadend',
|
|
433
|
+
() => {
|
|
434
|
+
pushNetwork({
|
|
435
|
+
id: `net_${Date.now()}_${networkSequence++}`,
|
|
436
|
+
kind: 'xhr',
|
|
437
|
+
method: self.__bakMeta?.method ?? 'GET',
|
|
438
|
+
url: self.__bakMeta?.url ?? window.location.href,
|
|
439
|
+
status: self.status,
|
|
440
|
+
ok: self.status >= 200 && self.status < 400,
|
|
441
|
+
ts: Date.now(),
|
|
442
|
+
durationMs: Math.max(0, performance.now() - (self.__bakMeta?.started ?? performance.now())),
|
|
443
|
+
requestBytes: self.__bakMeta?.requestBytes
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
{ once: true }
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
return xhrSend.call(this, body ?? null);
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (typeof PerformanceObserver !== 'undefined') {
|
|
454
|
+
try {
|
|
455
|
+
const observer = new PerformanceObserver((list) => {
|
|
456
|
+
for (const entry of list.getEntries()) {
|
|
457
|
+
longTaskCount += 1;
|
|
458
|
+
longTaskDurationMs += entry.duration;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
observer.observe({ entryTypes: ['longtask'] });
|
|
462
|
+
} catch {
|
|
463
|
+
// ignore browser without longtask support
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
patchConsoleCapture();
|
|
468
|
+
patchNetworkCapture();
|
|
469
|
+
|
|
470
|
+
const unsafeKeywords = /(submit|delete|remove|send|upload|付款|支付|删除|提交|发送|上传)/i;
|
|
471
|
+
|
|
472
|
+
function fnv1a(input: string): string {
|
|
473
|
+
let hash = 0x811c9dc5;
|
|
474
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
475
|
+
hash ^= input.charCodeAt(index);
|
|
476
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
477
|
+
}
|
|
478
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function inferRole(element: HTMLElement): string {
|
|
482
|
+
if (element.getAttribute('role')) {
|
|
483
|
+
return element.getAttribute('role') ?? 'generic';
|
|
484
|
+
}
|
|
485
|
+
switch (element.tagName.toLowerCase()) {
|
|
486
|
+
case 'a':
|
|
487
|
+
return 'link';
|
|
488
|
+
case 'button':
|
|
489
|
+
return 'button';
|
|
490
|
+
case 'input': {
|
|
491
|
+
const type = (element as HTMLInputElement).type;
|
|
492
|
+
if (type === 'checkbox') {
|
|
493
|
+
return 'checkbox';
|
|
494
|
+
}
|
|
495
|
+
if (type === 'radio') {
|
|
496
|
+
return 'radio';
|
|
497
|
+
}
|
|
498
|
+
return 'textbox';
|
|
499
|
+
}
|
|
500
|
+
case 'select':
|
|
501
|
+
return 'combobox';
|
|
502
|
+
case 'textarea':
|
|
503
|
+
return 'textbox';
|
|
504
|
+
default:
|
|
505
|
+
return 'generic';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function labelledByText(element: HTMLElement): string {
|
|
510
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
511
|
+
if (!labelledBy) {
|
|
512
|
+
return '';
|
|
513
|
+
}
|
|
514
|
+
return labelledBy
|
|
515
|
+
.split(/\s+/)
|
|
516
|
+
.map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
|
|
517
|
+
.filter(Boolean)
|
|
518
|
+
.join(' ');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function labelText(element: HTMLElement): string {
|
|
522
|
+
const ownerDocument = element.ownerDocument;
|
|
523
|
+
if (isInputElement(element) && element.id) {
|
|
524
|
+
const explicit = ownerDocument.querySelector(`label[for="${CSS.escape(element.id)}"]`);
|
|
525
|
+
if (explicit?.textContent) {
|
|
526
|
+
return explicit.textContent.trim();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const wrapper = element.closest('label');
|
|
531
|
+
return wrapper?.textContent?.trim() ?? '';
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function inferName(element: HTMLElement, options: RedactTextOptions = {}): string {
|
|
535
|
+
const inputType = isInputElement(element) ? element.type : null;
|
|
536
|
+
return inferSafeName(
|
|
537
|
+
{
|
|
538
|
+
tag: element.tagName,
|
|
539
|
+
role: inferRole(element),
|
|
540
|
+
inputType,
|
|
541
|
+
ariaLabel: element.getAttribute('aria-label'),
|
|
542
|
+
labelledByText: labelledByText(element),
|
|
543
|
+
labelText: labelText(element),
|
|
544
|
+
placeholder: isInputElement(element) || isTextAreaElement(element) ? element.placeholder : '',
|
|
545
|
+
text: element.innerText || element.textContent || '',
|
|
546
|
+
nameAttr: element.getAttribute('name')
|
|
547
|
+
},
|
|
548
|
+
options
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function isElementVisible(element: HTMLElement): boolean {
|
|
553
|
+
const style = window.getComputedStyle(element);
|
|
554
|
+
if (
|
|
555
|
+
style.display === 'none' ||
|
|
556
|
+
style.visibility === 'hidden' ||
|
|
557
|
+
Number.parseFloat(style.opacity || '1') <= 0
|
|
558
|
+
) {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true') {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const rect = element.getBoundingClientRect();
|
|
567
|
+
return rect.width > 1 && rect.height > 1;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isInteractive(element: HTMLElement): boolean {
|
|
571
|
+
const tag = element.tagName.toLowerCase();
|
|
572
|
+
const role = element.getAttribute('role');
|
|
573
|
+
if (['button', 'input', 'select', 'textarea', 'a'].includes(tag)) {
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
if (role && ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox'].includes(role)) {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
if (element.hasAttribute('contenteditable')) {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
const tabIndex = element.getAttribute('tabindex');
|
|
583
|
+
return tabIndex !== null && Number.parseInt(tabIndex, 10) >= 0;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function toCssSelector(element: HTMLElement): string | null {
|
|
587
|
+
if (element.id) {
|
|
588
|
+
return `#${CSS.escape(element.id)}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const name = element.getAttribute('name');
|
|
592
|
+
if (name) {
|
|
593
|
+
return `${element.tagName.toLowerCase()}[name="${CSS.escape(name)}"]`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const classes = Array.from(element.classList).slice(0, 2);
|
|
597
|
+
if (classes.length > 0) {
|
|
598
|
+
return `${element.tagName.toLowerCase()}.${classes.map((item) => CSS.escape(item)).join('.')}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const parent = element.parentElement;
|
|
602
|
+
if (!parent) {
|
|
603
|
+
return element.tagName.toLowerCase();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const siblings = Array.from(parent.children).filter(
|
|
607
|
+
(child) => child.tagName.toLowerCase() === element.tagName.toLowerCase()
|
|
608
|
+
);
|
|
609
|
+
const index = siblings.indexOf(element) + 1;
|
|
610
|
+
return `${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function buildEid(element: HTMLElement): string {
|
|
614
|
+
const rect = element.getBoundingClientRect();
|
|
615
|
+
const quantized = [
|
|
616
|
+
Math.round(rect.x / 10) * 10,
|
|
617
|
+
Math.round(rect.y / 10) * 10,
|
|
618
|
+
Math.round(rect.width / 10) * 10,
|
|
619
|
+
Math.round(rect.height / 10) * 10
|
|
620
|
+
].join(':');
|
|
621
|
+
|
|
622
|
+
const payload = [
|
|
623
|
+
window.location.hostname,
|
|
624
|
+
window.location.pathname,
|
|
625
|
+
inferRole(element),
|
|
626
|
+
inferName(element),
|
|
627
|
+
quantized
|
|
628
|
+
].join('|');
|
|
629
|
+
|
|
630
|
+
return `eid_${fnv1a(payload)}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function splitShadowSelector(selector: string): string[] {
|
|
634
|
+
return selector
|
|
635
|
+
.split('>>>')
|
|
636
|
+
.map((part) => part.trim())
|
|
637
|
+
.filter(Boolean);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function querySelectorInTree(root: ParentNode, selector: string): HTMLElement | null {
|
|
641
|
+
try {
|
|
642
|
+
return root.querySelector<HTMLElement>(selector);
|
|
643
|
+
} catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function querySelectorAcrossOpenShadow(root: ParentNode, selector: string): HTMLElement | null {
|
|
649
|
+
const direct = querySelectorInTree(root, selector);
|
|
650
|
+
if (direct) {
|
|
651
|
+
return direct;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const stack: ParentNode[] = [root];
|
|
655
|
+
while (stack.length > 0) {
|
|
656
|
+
const current = stack.pop()!;
|
|
657
|
+
const hosts = Array.from(current.querySelectorAll<HTMLElement>('*')).filter((item) => item.shadowRoot);
|
|
658
|
+
for (const host of hosts) {
|
|
659
|
+
if (!host.shadowRoot) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const found = querySelectorInTree(host.shadowRoot, selector);
|
|
663
|
+
if (found) {
|
|
664
|
+
return found;
|
|
665
|
+
}
|
|
666
|
+
stack.push(host.shadowRoot);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function querySelectorAllAcrossOpenShadow(root: ParentNode, selector: string): HTMLElement[] {
|
|
674
|
+
const collected: HTMLElement[] = [];
|
|
675
|
+
const seen = new Set<HTMLElement>();
|
|
676
|
+
|
|
677
|
+
const push = (element: HTMLElement): void => {
|
|
678
|
+
if (seen.has(element)) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
seen.add(element);
|
|
682
|
+
collected.push(element);
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
for (const found of Array.from(root.querySelectorAll<HTMLElement>(selector))) {
|
|
687
|
+
push(found);
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const stack: ParentNode[] = [root];
|
|
694
|
+
while (stack.length > 0) {
|
|
695
|
+
const current = stack.pop()!;
|
|
696
|
+
const hosts = Array.from(current.querySelectorAll<HTMLElement>('*')).filter((item) => item.shadowRoot);
|
|
697
|
+
for (const host of hosts) {
|
|
698
|
+
if (!host.shadowRoot) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
for (const found of Array.from(host.shadowRoot.querySelectorAll<HTMLElement>(selector))) {
|
|
703
|
+
push(found);
|
|
704
|
+
}
|
|
705
|
+
} catch {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
stack.push(host.shadowRoot);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return collected;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function resolveFrameDocument(framePath: string[]): { ok: true; document: Document } | { ok: false; error: ActionError } {
|
|
716
|
+
let currentDocument = document;
|
|
717
|
+
for (const selector of framePath) {
|
|
718
|
+
const frame = currentDocument.querySelector(selector);
|
|
719
|
+
if (!frame || !isFrameElement(frame)) {
|
|
720
|
+
return { ok: false, error: { code: 'E_NOT_FOUND', message: `frame not found: ${selector}` } };
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
const childDocument = frame.contentDocument;
|
|
724
|
+
if (!childDocument) {
|
|
725
|
+
return { ok: false, error: { code: 'E_NOT_READY', message: `frame document unavailable: ${selector}` } };
|
|
726
|
+
}
|
|
727
|
+
currentDocument = childDocument;
|
|
728
|
+
} catch {
|
|
729
|
+
return {
|
|
730
|
+
ok: false,
|
|
731
|
+
error: {
|
|
732
|
+
code: 'E_PERMISSION',
|
|
733
|
+
message: `cross-origin frame is not accessible: ${selector}`
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { ok: true, document: currentDocument };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function resolveShadowRoot(base: ParentNode, path: string[]): { ok: true; root: ParentNode } | { ok: false; error: ActionError } {
|
|
743
|
+
let root = base;
|
|
744
|
+
for (const selector of path) {
|
|
745
|
+
const host = querySelectorAcrossOpenShadow(root, selector);
|
|
746
|
+
if (!host) {
|
|
747
|
+
return { ok: false, error: { code: 'E_NOT_FOUND', message: `shadow host not found: ${selector}` } };
|
|
748
|
+
}
|
|
749
|
+
if (!host.shadowRoot) {
|
|
750
|
+
return { ok: false, error: { code: 'E_NOT_READY', message: `shadow root unavailable or closed: ${selector}` } };
|
|
751
|
+
}
|
|
752
|
+
root = host.shadowRoot;
|
|
753
|
+
}
|
|
754
|
+
return { ok: true, root };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function resolveRootForLocator(locator?: Locator): { ok: true; root: ParentNode } | { ok: false; error: ActionError } {
|
|
758
|
+
const framePath = [...contextState.framePath, ...(Array.isArray(locator?.framePath) ? locator.framePath : [])];
|
|
759
|
+
const frameResult = resolveFrameDocument(framePath);
|
|
760
|
+
if (!frameResult.ok) {
|
|
761
|
+
return frameResult;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const shadowPath = [...contextState.shadowPath];
|
|
765
|
+
const shadowResult = resolveShadowRoot(frameResult.document, shadowPath);
|
|
766
|
+
if (!shadowResult.ok) {
|
|
767
|
+
return shadowResult;
|
|
768
|
+
}
|
|
769
|
+
return { ok: true, root: shadowResult.root };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function collectElements(options: RedactTextOptions = {}, locator?: Locator): ElementMapItem[] {
|
|
773
|
+
const rootResult = resolveRootForLocator(locator);
|
|
774
|
+
if (!rootResult.ok) {
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const root = rootResult.root;
|
|
779
|
+
const nodes = Array.from(root.querySelectorAll<HTMLElement>('*'));
|
|
780
|
+
const results: ElementMapItem[] = [];
|
|
781
|
+
elementCache.clear();
|
|
782
|
+
|
|
783
|
+
for (const element of nodes) {
|
|
784
|
+
if (!isInteractive(element) || !isElementVisible(element)) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const rect = element.getBoundingClientRect();
|
|
789
|
+
const name = inferName(element, options);
|
|
790
|
+
const role = inferRole(element);
|
|
791
|
+
const text = redactElementText(element.innerText || element.textContent || '', options);
|
|
792
|
+
const eid = buildEid(element);
|
|
793
|
+
const selectors = {
|
|
794
|
+
css: toCssSelector(element),
|
|
795
|
+
text: text || name ? (text || name).slice(0, 80) : null,
|
|
796
|
+
aria: role && name ? `${role}:${name.slice(0, 80)}` : null
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const combined = `${name} ${text}`;
|
|
800
|
+
const risk = unsafeKeywords.test(combined) || (isInputElement(element) && element.type === 'file') ? 'high' : 'low';
|
|
801
|
+
|
|
802
|
+
const item: ElementMapItem = {
|
|
803
|
+
eid,
|
|
804
|
+
tag: element.tagName.toLowerCase(),
|
|
805
|
+
role,
|
|
806
|
+
name,
|
|
807
|
+
text,
|
|
808
|
+
bbox: {
|
|
809
|
+
x: rect.x,
|
|
810
|
+
y: rect.y,
|
|
811
|
+
width: rect.width,
|
|
812
|
+
height: rect.height
|
|
813
|
+
},
|
|
814
|
+
selectors,
|
|
815
|
+
risk
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
results.push(item);
|
|
819
|
+
elementCache.set(eid, element);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return results;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function getInteractiveElements(root: ParentNode, includeShadow = true): HTMLElement[] {
|
|
826
|
+
const results = Array.from(root.querySelectorAll<HTMLElement>('*')).filter(
|
|
827
|
+
(element) => isInteractive(element) && isElementVisible(element)
|
|
828
|
+
);
|
|
829
|
+
if (!includeShadow) {
|
|
830
|
+
return results;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const queue = [...Array.from(root.querySelectorAll<HTMLElement>('*')).filter((element) => element.shadowRoot)];
|
|
834
|
+
while (queue.length > 0) {
|
|
835
|
+
const host = queue.shift()!;
|
|
836
|
+
if (!host.shadowRoot) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
for (const element of Array.from(host.shadowRoot.querySelectorAll<HTMLElement>('*'))) {
|
|
841
|
+
if (element.shadowRoot) {
|
|
842
|
+
queue.push(element);
|
|
843
|
+
}
|
|
844
|
+
if (isInteractive(element) && isElementVisible(element)) {
|
|
845
|
+
results.push(element);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return results;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function resolveLocator(locator?: Locator): HTMLElement | null {
|
|
853
|
+
if (!locator) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (locator.eid) {
|
|
858
|
+
const refreshed = collectElements({}, locator);
|
|
859
|
+
if (refreshed.length > 0) {
|
|
860
|
+
const fromCache = elementCache.get(locator.eid);
|
|
861
|
+
if (fromCache) {
|
|
862
|
+
return fromCache;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const rootResult = resolveRootForLocator(locator);
|
|
868
|
+
if (!rootResult.ok) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const root = rootResult.root;
|
|
872
|
+
const interactive = getInteractiveElements(root, locator.shadow !== 'none');
|
|
873
|
+
|
|
874
|
+
if (locator.role || locator.name) {
|
|
875
|
+
const role = locator.role?.toLowerCase();
|
|
876
|
+
const name = locator.name?.toLowerCase();
|
|
877
|
+
const matches = interactive.filter((element) => {
|
|
878
|
+
const roleMatch = role ? inferRole(element).toLowerCase() === role : true;
|
|
879
|
+
const nameMatch = name ? inferName(element).toLowerCase().includes(name) : true;
|
|
880
|
+
return roleMatch && nameMatch;
|
|
881
|
+
});
|
|
882
|
+
const found = indexedCandidate(matches, locator);
|
|
883
|
+
if (found) {
|
|
884
|
+
return found;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (locator.text) {
|
|
889
|
+
const needle = locator.text.toLowerCase();
|
|
890
|
+
const matches = interactive.filter((element) => {
|
|
891
|
+
const text = (element.innerText || element.textContent || '').toLowerCase();
|
|
892
|
+
return text.includes(needle);
|
|
893
|
+
});
|
|
894
|
+
const found = indexedCandidate(matches, locator);
|
|
895
|
+
if (found) {
|
|
896
|
+
return found;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (locator.css) {
|
|
901
|
+
const parts = splitShadowSelector(locator.css);
|
|
902
|
+
let currentRoot: ParentNode = root;
|
|
903
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
904
|
+
const selector = parts[index];
|
|
905
|
+
const found =
|
|
906
|
+
locator.shadow === 'none'
|
|
907
|
+
? querySelectorInTree(currentRoot, selector)
|
|
908
|
+
: querySelectorAcrossOpenShadow(currentRoot, selector);
|
|
909
|
+
if (!found) {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
if (index === parts.length - 1) {
|
|
913
|
+
let matches: HTMLElement[] = [];
|
|
914
|
+
if (locator.shadow === 'none') {
|
|
915
|
+
try {
|
|
916
|
+
matches = Array.from(currentRoot.querySelectorAll<HTMLElement>(selector));
|
|
917
|
+
} catch {
|
|
918
|
+
matches = [];
|
|
919
|
+
}
|
|
920
|
+
} else {
|
|
921
|
+
matches = querySelectorAllAcrossOpenShadow(currentRoot, selector);
|
|
922
|
+
}
|
|
923
|
+
const visibleMatches = matches.filter((item) => isElementVisible(item));
|
|
924
|
+
return indexedCandidate(visibleMatches, locator) ?? null;
|
|
925
|
+
}
|
|
926
|
+
if (!found.shadowRoot) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
currentRoot = found.shadowRoot;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (locator.name) {
|
|
934
|
+
const fallback = indexedCandidate(
|
|
935
|
+
interactive.filter((element) => inferName(element).toLowerCase().includes(locator.name!.toLowerCase())),
|
|
936
|
+
locator
|
|
937
|
+
);
|
|
938
|
+
if (fallback) {
|
|
939
|
+
return fallback;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function ensureOverlayRoot(): HTMLDivElement {
|
|
947
|
+
let root = document.getElementById('bak-overlay-root') as HTMLDivElement | null;
|
|
948
|
+
if (!root) {
|
|
949
|
+
root = document.createElement('div');
|
|
950
|
+
root.id = 'bak-overlay-root';
|
|
951
|
+
root.style.position = 'fixed';
|
|
952
|
+
root.style.bottom = '12px';
|
|
953
|
+
root.style.right = '12px';
|
|
954
|
+
root.style.zIndex = '2147483647';
|
|
955
|
+
root.style.fontFamily = 'ui-sans-serif, system-ui';
|
|
956
|
+
document.body.appendChild(root);
|
|
957
|
+
|
|
958
|
+
const badge = document.createElement('div');
|
|
959
|
+
badge.textContent = 'BAK';
|
|
960
|
+
badge.style.background = '#0f172a';
|
|
961
|
+
badge.style.color = '#ffffff';
|
|
962
|
+
badge.style.padding = '6px 10px';
|
|
963
|
+
badge.style.borderRadius = '999px';
|
|
964
|
+
badge.style.fontSize = '12px';
|
|
965
|
+
badge.style.boxShadow = '0 6px 20px rgba(2, 6, 23, 0.35)';
|
|
966
|
+
root.appendChild(badge);
|
|
967
|
+
}
|
|
968
|
+
return root;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function askConfirm(message: string): Promise<boolean> {
|
|
972
|
+
ensureOverlayRoot();
|
|
973
|
+
|
|
974
|
+
return new Promise<boolean>((resolve) => {
|
|
975
|
+
const panel = document.createElement('div');
|
|
976
|
+
panel.style.position = 'fixed';
|
|
977
|
+
panel.style.right = '12px';
|
|
978
|
+
panel.style.bottom = '52px';
|
|
979
|
+
panel.style.width = '320px';
|
|
980
|
+
panel.style.background = '#fff';
|
|
981
|
+
panel.style.border = '1px solid #e2e8f0';
|
|
982
|
+
panel.style.borderRadius = '8px';
|
|
983
|
+
panel.style.boxShadow = '0 12px 30px rgba(15, 23, 42, 0.22)';
|
|
984
|
+
panel.style.padding = '12px';
|
|
985
|
+
panel.style.zIndex = '2147483647';
|
|
986
|
+
|
|
987
|
+
const title = document.createElement('div');
|
|
988
|
+
title.textContent = 'High-risk action requires confirmation';
|
|
989
|
+
title.style.fontSize = '13px';
|
|
990
|
+
title.style.fontWeight = '600';
|
|
991
|
+
|
|
992
|
+
const desc = document.createElement('div');
|
|
993
|
+
desc.textContent = message;
|
|
994
|
+
desc.style.marginTop = '8px';
|
|
995
|
+
desc.style.fontSize = '12px';
|
|
996
|
+
desc.style.color = '#334155';
|
|
997
|
+
|
|
998
|
+
const row = document.createElement('div');
|
|
999
|
+
row.style.marginTop = '12px';
|
|
1000
|
+
row.style.display = 'flex';
|
|
1001
|
+
row.style.gap = '8px';
|
|
1002
|
+
|
|
1003
|
+
const approve = document.createElement('button');
|
|
1004
|
+
approve.textContent = 'Approve';
|
|
1005
|
+
approve.style.border = 'none';
|
|
1006
|
+
approve.style.background = '#16a34a';
|
|
1007
|
+
approve.style.color = '#fff';
|
|
1008
|
+
approve.style.padding = '8px 10px';
|
|
1009
|
+
approve.style.borderRadius = '6px';
|
|
1010
|
+
approve.style.cursor = 'pointer';
|
|
1011
|
+
|
|
1012
|
+
const reject = document.createElement('button');
|
|
1013
|
+
reject.textContent = 'Reject';
|
|
1014
|
+
reject.style.border = '1px solid #cbd5e1';
|
|
1015
|
+
reject.style.background = '#fff';
|
|
1016
|
+
reject.style.color = '#0f172a';
|
|
1017
|
+
reject.style.padding = '8px 10px';
|
|
1018
|
+
reject.style.borderRadius = '6px';
|
|
1019
|
+
reject.style.cursor = 'pointer';
|
|
1020
|
+
|
|
1021
|
+
const done = (value: boolean): void => {
|
|
1022
|
+
panel.remove();
|
|
1023
|
+
resolve(value);
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
approve.addEventListener('click', () => done(true));
|
|
1027
|
+
reject.addEventListener('click', () => done(false));
|
|
1028
|
+
|
|
1029
|
+
row.appendChild(approve);
|
|
1030
|
+
row.appendChild(reject);
|
|
1031
|
+
panel.appendChild(title);
|
|
1032
|
+
panel.appendChild(desc);
|
|
1033
|
+
panel.appendChild(row);
|
|
1034
|
+
document.body.appendChild(panel);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function highlightCandidates(candidates: ElementMapItem[]): Array<() => void> {
|
|
1039
|
+
const disposers: Array<() => void> = [];
|
|
1040
|
+
|
|
1041
|
+
for (const candidate of candidates) {
|
|
1042
|
+
const element = elementCache.get(candidate.eid) ?? resolveLocator({ eid: candidate.eid });
|
|
1043
|
+
if (!element) {
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
const oldOutline = element.style.outline;
|
|
1047
|
+
element.style.outline = '2px solid #f97316';
|
|
1048
|
+
disposers.push(() => {
|
|
1049
|
+
element.style.outline = oldOutline;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return disposers;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function pickCandidate(candidates: ElementMapItem[]): Promise<string | null> {
|
|
1057
|
+
collectElements();
|
|
1058
|
+
return new Promise<string | null>((resolve) => {
|
|
1059
|
+
const disposers = highlightCandidates(candidates);
|
|
1060
|
+
|
|
1061
|
+
const panel = document.createElement('div');
|
|
1062
|
+
panel.style.position = 'fixed';
|
|
1063
|
+
panel.style.right = '12px';
|
|
1064
|
+
panel.style.bottom = '52px';
|
|
1065
|
+
panel.style.width = '340px';
|
|
1066
|
+
panel.style.maxHeight = '320px';
|
|
1067
|
+
panel.style.overflow = 'auto';
|
|
1068
|
+
panel.style.background = '#fff';
|
|
1069
|
+
panel.style.border = '1px solid #e2e8f0';
|
|
1070
|
+
panel.style.borderRadius = '8px';
|
|
1071
|
+
panel.style.boxShadow = '0 12px 30px rgba(15, 23, 42, 0.22)';
|
|
1072
|
+
panel.style.padding = '12px';
|
|
1073
|
+
panel.style.zIndex = '2147483647';
|
|
1074
|
+
|
|
1075
|
+
const title = document.createElement('div');
|
|
1076
|
+
title.textContent = 'Action failed, choose a target to heal skill';
|
|
1077
|
+
title.style.fontWeight = '600';
|
|
1078
|
+
title.style.fontSize = '13px';
|
|
1079
|
+
panel.appendChild(title);
|
|
1080
|
+
|
|
1081
|
+
const cleanup = (value: string | null): void => {
|
|
1082
|
+
panel.remove();
|
|
1083
|
+
for (const dispose of disposers) {
|
|
1084
|
+
dispose();
|
|
1085
|
+
}
|
|
1086
|
+
resolve(value);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
for (const candidate of candidates.slice(0, 3)) {
|
|
1090
|
+
const btn = document.createElement('button');
|
|
1091
|
+
btn.textContent = `${candidate.role ?? candidate.tag}: ${candidate.name || candidate.text || candidate.eid}`;
|
|
1092
|
+
btn.style.display = 'block';
|
|
1093
|
+
btn.style.width = '100%';
|
|
1094
|
+
btn.style.marginTop = '8px';
|
|
1095
|
+
btn.style.textAlign = 'left';
|
|
1096
|
+
btn.style.border = '1px solid #cbd5e1';
|
|
1097
|
+
btn.style.borderRadius = '6px';
|
|
1098
|
+
btn.style.padding = '8px';
|
|
1099
|
+
btn.style.background = '#f8fafc';
|
|
1100
|
+
btn.style.cursor = 'pointer';
|
|
1101
|
+
btn.addEventListener('click', () => cleanup(candidate.eid));
|
|
1102
|
+
panel.appendChild(btn);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const cancel = document.createElement('button');
|
|
1106
|
+
cancel.textContent = 'Cancel';
|
|
1107
|
+
cancel.style.marginTop = '8px';
|
|
1108
|
+
cancel.style.border = 'none';
|
|
1109
|
+
cancel.style.background = '#e2e8f0';
|
|
1110
|
+
cancel.style.padding = '8px 10px';
|
|
1111
|
+
cancel.style.borderRadius = '6px';
|
|
1112
|
+
cancel.style.cursor = 'pointer';
|
|
1113
|
+
cancel.addEventListener('click', () => cleanup(null));
|
|
1114
|
+
panel.appendChild(cancel);
|
|
1115
|
+
|
|
1116
|
+
document.body.appendChild(panel);
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function failAction(code: string, message: string, data?: Record<string, unknown>): ActionResult {
|
|
1121
|
+
return { ok: false, error: { code, message, data } };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function failAssessment(code: string, message: string, data?: Record<string, unknown>): ActionAssessment {
|
|
1125
|
+
return { ok: false, error: { code, message, data } };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function indexedCandidate<T>(candidates: T[], locator?: Locator): T | undefined {
|
|
1129
|
+
if (candidates.length === 0) {
|
|
1130
|
+
return undefined;
|
|
1131
|
+
}
|
|
1132
|
+
const index = typeof locator?.index === 'number' ? Math.max(0, Math.floor(locator.index)) : 0;
|
|
1133
|
+
return candidates[index];
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function notFoundForLocator(locator: Locator | undefined, message: string): ActionError {
|
|
1137
|
+
const hint = unsupportedLocatorHint(locator);
|
|
1138
|
+
return {
|
|
1139
|
+
code: 'E_NOT_FOUND',
|
|
1140
|
+
message,
|
|
1141
|
+
data: hint ? { hint } : undefined
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function describeNode(node: Element | null): string {
|
|
1146
|
+
if (!isHtmlElement(node)) {
|
|
1147
|
+
return 'unknown';
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const id = node.id ? `#${node.id}` : '';
|
|
1151
|
+
const classes = node.classList.length > 0 ? `.${Array.from(node.classList).slice(0, 2).join('.')}` : '';
|
|
1152
|
+
return `${node.tagName.toLowerCase()}${id}${classes}`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function centerPoint(rect: DOMRect): { x: number; y: number } {
|
|
1156
|
+
const minX = Math.max(1, Math.floor(rect.left + 1));
|
|
1157
|
+
const minY = Math.max(1, Math.floor(rect.top + 1));
|
|
1158
|
+
const maxX = Math.max(minX, Math.floor(rect.right - 1));
|
|
1159
|
+
const maxY = Math.max(minY, Math.floor(rect.bottom - 1));
|
|
1160
|
+
|
|
1161
|
+
const x = Math.min(Math.max(Math.floor(rect.left + rect.width / 2), minX), maxX);
|
|
1162
|
+
const y = Math.min(Math.max(Math.floor(rect.top + rect.height / 2), minY), maxY);
|
|
1163
|
+
return { x, y };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function assessActionTarget(target: HTMLElement, action: ActionName): ActionAssessment {
|
|
1167
|
+
if (!isElementVisible(target)) {
|
|
1168
|
+
return failAssessment('E_NOT_FOUND', `${action} target is not visible`);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if ((target as HTMLButtonElement).disabled || target.getAttribute('aria-disabled') === 'true') {
|
|
1172
|
+
return failAssessment('E_PERMISSION', `${action} target is disabled`);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' });
|
|
1176
|
+
const rect = target.getBoundingClientRect();
|
|
1177
|
+
if (rect.width <= 1 || rect.height <= 1) {
|
|
1178
|
+
return failAssessment('E_NOT_FOUND', `${action} target has invalid bounds`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const ownerDocument = target.ownerDocument ?? document;
|
|
1182
|
+
const point = centerPoint(rect);
|
|
1183
|
+
const hit = ownerDocument.elementFromPoint(point.x, point.y);
|
|
1184
|
+
if (!hit) {
|
|
1185
|
+
return failAssessment('E_NOT_FOUND', `${action} target is outside viewport`);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let shadowHostBridge = false;
|
|
1189
|
+
const rootNode = target.getRootNode();
|
|
1190
|
+
if (rootNode instanceof ShadowRoot) {
|
|
1191
|
+
shadowHostBridge = hit === rootNode.host;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (hit !== target && !target.contains(hit) && !shadowHostBridge) {
|
|
1195
|
+
return failAssessment('E_PERMISSION', `${action} target is obstructed by ${describeNode(hit)}`, {
|
|
1196
|
+
obstructedBy: describeNode(hit)
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return { ok: true, point };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function dispatchPointer(target: HTMLElement, type: string, point: { x: number; y: number }): void {
|
|
1204
|
+
if (typeof PointerEvent === 'undefined') {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
target.dispatchEvent(
|
|
1209
|
+
new PointerEvent(type, {
|
|
1210
|
+
bubbles: true,
|
|
1211
|
+
cancelable: true,
|
|
1212
|
+
composed: true,
|
|
1213
|
+
clientX: point.x,
|
|
1214
|
+
clientY: point.y,
|
|
1215
|
+
pointerId: 1,
|
|
1216
|
+
pointerType: 'mouse',
|
|
1217
|
+
isPrimary: true,
|
|
1218
|
+
button: 0,
|
|
1219
|
+
buttons: type === 'pointerup' ? 0 : 1
|
|
1220
|
+
})
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function dispatchMouse(target: HTMLElement, type: string, point: { x: number; y: number }): void {
|
|
1225
|
+
target.dispatchEvent(
|
|
1226
|
+
new MouseEvent(type, {
|
|
1227
|
+
bubbles: true,
|
|
1228
|
+
cancelable: true,
|
|
1229
|
+
composed: true,
|
|
1230
|
+
clientX: point.x,
|
|
1231
|
+
clientY: point.y,
|
|
1232
|
+
button: 0,
|
|
1233
|
+
buttons: type === 'mouseup' || type === 'click' ? 0 : 1
|
|
1234
|
+
})
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function performClick(target: HTMLElement, point: { x: number; y: number }): void {
|
|
1239
|
+
target.focus({ preventScroll: true });
|
|
1240
|
+
dispatchPointer(target, 'pointerdown', point);
|
|
1241
|
+
dispatchMouse(target, 'mousedown', point);
|
|
1242
|
+
dispatchPointer(target, 'pointerup', point);
|
|
1243
|
+
dispatchMouse(target, 'mouseup', point);
|
|
1244
|
+
dispatchMouse(target, 'click', point);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function dispatchInputEvents(target: HTMLElement): void {
|
|
1248
|
+
try {
|
|
1249
|
+
target.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }));
|
|
1250
|
+
} catch {
|
|
1251
|
+
target.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
1252
|
+
}
|
|
1253
|
+
target.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function setNativeValue(target: HTMLInputElement | HTMLTextAreaElement, nextValue: string): void {
|
|
1257
|
+
const proto = isInputElement(target)
|
|
1258
|
+
? (target.ownerDocument.defaultView?.HTMLInputElement.prototype ?? HTMLInputElement.prototype)
|
|
1259
|
+
: (target.ownerDocument.defaultView?.HTMLTextAreaElement.prototype ?? HTMLTextAreaElement.prototype);
|
|
1260
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
1261
|
+
if (descriptor?.set) {
|
|
1262
|
+
descriptor.set.call(target, nextValue);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
target.value = nextValue;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function insertContentEditableText(target: HTMLElement, text: string, clear: boolean): void {
|
|
1269
|
+
target.focus({ preventScroll: true });
|
|
1270
|
+
|
|
1271
|
+
if (clear) {
|
|
1272
|
+
target.textContent = '';
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (!text) {
|
|
1276
|
+
dispatchInputEvents(target);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const selection = window.getSelection();
|
|
1281
|
+
if (!selection) {
|
|
1282
|
+
target.append(document.createTextNode(text));
|
|
1283
|
+
dispatchInputEvents(target);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const range = document.createRange();
|
|
1288
|
+
range.selectNodeContents(target);
|
|
1289
|
+
range.collapse(false);
|
|
1290
|
+
selection.removeAllRanges();
|
|
1291
|
+
selection.addRange(range);
|
|
1292
|
+
|
|
1293
|
+
const node = document.createTextNode(text);
|
|
1294
|
+
range.insertNode(node);
|
|
1295
|
+
range.setStartAfter(node);
|
|
1296
|
+
range.setEndAfter(node);
|
|
1297
|
+
selection.removeAllRanges();
|
|
1298
|
+
selection.addRange(range);
|
|
1299
|
+
dispatchInputEvents(target);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function isEditable(target: HTMLElement): target is HTMLInputElement | HTMLTextAreaElement {
|
|
1303
|
+
return isInputElement(target) || isTextAreaElement(target);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function keyEvent(type: 'keydown' | 'keyup', key: string, extra?: Partial<KeyboardEventInit>): KeyboardEvent {
|
|
1307
|
+
return new KeyboardEvent(type, {
|
|
1308
|
+
bubbles: true,
|
|
1309
|
+
cancelable: true,
|
|
1310
|
+
composed: true,
|
|
1311
|
+
key,
|
|
1312
|
+
code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
|
|
1313
|
+
...extra
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function parseHotkey(keys: string[]): {
|
|
1318
|
+
key: string;
|
|
1319
|
+
ctrlKey: boolean;
|
|
1320
|
+
altKey: boolean;
|
|
1321
|
+
shiftKey: boolean;
|
|
1322
|
+
metaKey: boolean;
|
|
1323
|
+
} {
|
|
1324
|
+
const lowered = keys.map((item) => item.toLowerCase());
|
|
1325
|
+
const key = keys.find((item) => !['ctrl', 'control', 'alt', 'shift', 'meta', 'cmd'].includes(item.toLowerCase())) ?? keys[0] ?? '';
|
|
1326
|
+
return {
|
|
1327
|
+
key,
|
|
1328
|
+
ctrlKey: lowered.includes('ctrl') || lowered.includes('control'),
|
|
1329
|
+
altKey: lowered.includes('alt'),
|
|
1330
|
+
shiftKey: lowered.includes('shift'),
|
|
1331
|
+
metaKey: lowered.includes('meta') || lowered.includes('cmd')
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function domSummary(): PageDomSummary {
|
|
1336
|
+
const allElements = Array.from(document.querySelectorAll('*'));
|
|
1337
|
+
const interactiveElements = Array.from(document.querySelectorAll<HTMLElement>('*')).filter((element) => isInteractive(element));
|
|
1338
|
+
const tags = new Map<string, number>();
|
|
1339
|
+
|
|
1340
|
+
for (const element of allElements) {
|
|
1341
|
+
const tag = element.tagName.toLowerCase();
|
|
1342
|
+
tags.set(tag, (tags.get(tag) ?? 0) + 1);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const tagHistogram = [...tags.entries()]
|
|
1346
|
+
.sort((a, b) => b[1] - a[1])
|
|
1347
|
+
.slice(0, 20)
|
|
1348
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
1349
|
+
|
|
1350
|
+
const shadowHosts = Array.from(document.querySelectorAll<HTMLElement>('*')).filter((element) => element.shadowRoot).length;
|
|
1351
|
+
|
|
1352
|
+
return {
|
|
1353
|
+
url: window.location.href,
|
|
1354
|
+
title: document.title,
|
|
1355
|
+
totalElements: allElements.length,
|
|
1356
|
+
interactiveElements: interactiveElements.length,
|
|
1357
|
+
headings: document.querySelectorAll('h1,h2,h3,h4,h5,h6').length,
|
|
1358
|
+
links: document.querySelectorAll('a[href]').length,
|
|
1359
|
+
forms: document.querySelectorAll('form').length,
|
|
1360
|
+
iframes: document.querySelectorAll('iframe,frame').length,
|
|
1361
|
+
shadowHosts,
|
|
1362
|
+
tagHistogram
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function pageTextChunks(maxChunks = 24, chunkSize = 320): PageTextChunk[] {
|
|
1367
|
+
const nodes = Array.from(document.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,td,th,label,button,a,span,div'));
|
|
1368
|
+
const chunks: PageTextChunk[] = [];
|
|
1369
|
+
|
|
1370
|
+
for (const node of nodes) {
|
|
1371
|
+
if (!isElementVisible(node)) {
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const text = (node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1376
|
+
if (!text) {
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
chunks.push({
|
|
1381
|
+
chunkId: `chunk_${chunks.length + 1}`,
|
|
1382
|
+
text: text.slice(0, chunkSize),
|
|
1383
|
+
sourceTag: node.tagName.toLowerCase()
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
if (chunks.length >= maxChunks) {
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return chunks;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function pageAccessibility(limit = 200): AccessibilityNode[] {
|
|
1395
|
+
const nodes: AccessibilityNode[] = [];
|
|
1396
|
+
for (const element of getInteractiveElements(document, true).slice(0, limit)) {
|
|
1397
|
+
nodes.push({
|
|
1398
|
+
role: inferRole(element),
|
|
1399
|
+
name: inferName(element),
|
|
1400
|
+
tag: element.tagName.toLowerCase(),
|
|
1401
|
+
eid: buildEid(element)
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return nodes;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function pageMetrics(): PageMetrics {
|
|
1408
|
+
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
1409
|
+
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
|
1410
|
+
return {
|
|
1411
|
+
navigation: {
|
|
1412
|
+
durationMs: navigation?.duration ?? 0,
|
|
1413
|
+
domContentLoadedMs: navigation ? navigation.domContentLoadedEventEnd - navigation.startTime : 0,
|
|
1414
|
+
loadEventMs: navigation ? navigation.loadEventEnd - navigation.startTime : 0
|
|
1415
|
+
},
|
|
1416
|
+
longTasks: {
|
|
1417
|
+
count: longTaskCount,
|
|
1418
|
+
totalDurationMs: Number(longTaskDurationMs.toFixed(2))
|
|
1419
|
+
},
|
|
1420
|
+
resources: {
|
|
1421
|
+
count: resources.length,
|
|
1422
|
+
transferSize: resources.reduce((sum, item) => sum + (item.transferSize ?? 0), 0),
|
|
1423
|
+
encodedBodySize: resources.reduce((sum, item) => sum + (item.encodedBodySize ?? 0), 0)
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function performanceNetworkEntries(): NetworkEntry[] {
|
|
1429
|
+
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
|
1430
|
+
const now = Date.now();
|
|
1431
|
+
const perfNow = performance.now();
|
|
1432
|
+
const entries: NetworkEntry[] = [];
|
|
1433
|
+
|
|
1434
|
+
for (let index = 0; index < resources.length; index += 1) {
|
|
1435
|
+
const resource = resources[index];
|
|
1436
|
+
if (resource.startTime < performanceBaselineMs) {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const responseEnd = resource.responseEnd > 0 ? resource.responseEnd : resource.startTime + resource.duration;
|
|
1441
|
+
const ts = now - Math.max(0, Math.round(perfNow - responseEnd));
|
|
1442
|
+
entries.push({
|
|
1443
|
+
id: `perf_${index}_${Math.round(resource.startTime)}_${fnv1a(resource.name).slice(0, 8)}`,
|
|
1444
|
+
kind: 'resource',
|
|
1445
|
+
method: 'GET',
|
|
1446
|
+
url: resource.name,
|
|
1447
|
+
status: 0,
|
|
1448
|
+
ok: true,
|
|
1449
|
+
ts,
|
|
1450
|
+
durationMs: Math.max(0, resource.duration),
|
|
1451
|
+
responseBytes: resource.transferSize > 0 ? resource.transferSize : undefined
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
return entries;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function networkSnapshotEntries(): NetworkEntry[] {
|
|
1459
|
+
const merged = [...networkEntries];
|
|
1460
|
+
const seen = new Set(merged.map((entry) => `${entry.kind}|${entry.url}|${Math.round(entry.ts / 10)}`));
|
|
1461
|
+
|
|
1462
|
+
for (const entry of performanceNetworkEntries()) {
|
|
1463
|
+
const key = `${entry.kind}|${entry.url}|${Math.round(entry.ts / 10)}`;
|
|
1464
|
+
if (seen.has(key)) {
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
seen.add(key);
|
|
1468
|
+
merged.push(entry);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
merged.sort((a, b) => a.ts - b.ts);
|
|
1472
|
+
return merged;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function filterNetworkEntries(params: Record<string, unknown>): NetworkEntry[] {
|
|
1476
|
+
const urlIncludes = typeof params.urlIncludes === 'string' ? params.urlIncludes : '';
|
|
1477
|
+
const method = typeof params.method === 'string' ? params.method.toUpperCase() : '';
|
|
1478
|
+
const status = typeof params.status === 'number' ? params.status : undefined;
|
|
1479
|
+
const sinceTs = typeof params.sinceTs === 'number' ? params.sinceTs : undefined;
|
|
1480
|
+
const limit = typeof params.limit === 'number' ? Math.max(1, Math.min(500, Math.floor(params.limit))) : 50;
|
|
1481
|
+
|
|
1482
|
+
return networkSnapshotEntries()
|
|
1483
|
+
.filter((entry) => {
|
|
1484
|
+
if (typeof sinceTs === 'number' && entry.ts < sinceTs) {
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
if (urlIncludes && !entry.url.includes(urlIncludes)) {
|
|
1488
|
+
return false;
|
|
1489
|
+
}
|
|
1490
|
+
if (method && entry.method.toUpperCase() !== method) {
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
if (typeof status === 'number' && entry.status !== status) {
|
|
1494
|
+
return false;
|
|
1495
|
+
}
|
|
1496
|
+
return true;
|
|
1497
|
+
})
|
|
1498
|
+
.slice(-limit)
|
|
1499
|
+
.reverse();
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
async function waitForNetwork(params: Record<string, unknown>): Promise<NetworkEntry> {
|
|
1503
|
+
const timeoutMs = typeof params.timeoutMs === 'number' ? Math.max(1, params.timeoutMs) : 5000;
|
|
1504
|
+
const sinceTs = typeof params.sinceTs === 'number' ? params.sinceTs : Date.now() - timeoutMs;
|
|
1505
|
+
const start = Date.now();
|
|
1506
|
+
while (Date.now() - start < timeoutMs) {
|
|
1507
|
+
const matched = filterNetworkEntries({ ...params, sinceTs, limit: 1 })[0];
|
|
1508
|
+
if (matched) {
|
|
1509
|
+
return matched;
|
|
1510
|
+
}
|
|
1511
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1512
|
+
}
|
|
1513
|
+
throw { code: 'E_TIMEOUT', message: 'network.waitFor timeout' } satisfies ActionError;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function performDoubleClick(target: HTMLElement, point: { x: number; y: number }): void {
|
|
1517
|
+
performClick(target, point);
|
|
1518
|
+
performClick(target, point);
|
|
1519
|
+
dispatchMouse(target, 'dblclick', point);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function performRightClick(target: HTMLElement, point: { x: number; y: number }): void {
|
|
1523
|
+
dispatchPointer(target, 'pointerdown', point);
|
|
1524
|
+
target.dispatchEvent(
|
|
1525
|
+
new MouseEvent('mousedown', {
|
|
1526
|
+
bubbles: true,
|
|
1527
|
+
cancelable: true,
|
|
1528
|
+
composed: true,
|
|
1529
|
+
clientX: point.x,
|
|
1530
|
+
clientY: point.y,
|
|
1531
|
+
button: 2,
|
|
1532
|
+
buttons: 2
|
|
1533
|
+
})
|
|
1534
|
+
);
|
|
1535
|
+
dispatchPointer(target, 'pointerup', point);
|
|
1536
|
+
target.dispatchEvent(
|
|
1537
|
+
new MouseEvent('mouseup', {
|
|
1538
|
+
bubbles: true,
|
|
1539
|
+
cancelable: true,
|
|
1540
|
+
composed: true,
|
|
1541
|
+
clientX: point.x,
|
|
1542
|
+
clientY: point.y,
|
|
1543
|
+
button: 2,
|
|
1544
|
+
buttons: 0
|
|
1545
|
+
})
|
|
1546
|
+
);
|
|
1547
|
+
target.dispatchEvent(
|
|
1548
|
+
new MouseEvent('contextmenu', {
|
|
1549
|
+
bubbles: true,
|
|
1550
|
+
cancelable: true,
|
|
1551
|
+
composed: true,
|
|
1552
|
+
clientX: point.x,
|
|
1553
|
+
clientY: point.y,
|
|
1554
|
+
button: 2
|
|
1555
|
+
})
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function handleAction(message: ActionMessage): Promise<ActionResult> {
|
|
1560
|
+
try {
|
|
1561
|
+
ensureOverlayRoot();
|
|
1562
|
+
|
|
1563
|
+
if (message.action === 'scroll') {
|
|
1564
|
+
if (message.locator) {
|
|
1565
|
+
const target = resolveLocator(message.locator);
|
|
1566
|
+
if (!target) {
|
|
1567
|
+
return failAction('E_NOT_FOUND', 'scroll target not found');
|
|
1568
|
+
}
|
|
1569
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1570
|
+
return { ok: true };
|
|
1571
|
+
}
|
|
1572
|
+
window.scrollBy({ left: Number(message.dx ?? 0), top: Number(message.dy ?? 320), behavior: 'smooth' });
|
|
1573
|
+
return { ok: true };
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const target = resolveLocator(message.locator);
|
|
1577
|
+
if (!target) {
|
|
1578
|
+
return { ok: false, error: notFoundForLocator(message.locator, 'Target not found') };
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const name = inferName(target);
|
|
1582
|
+
const text = (target.innerText || target.textContent || '').trim();
|
|
1583
|
+
const riskText = `${name} ${text}`;
|
|
1584
|
+
const isHighRisk =
|
|
1585
|
+
message.requiresConfirm === true || unsafeKeywords.test(riskText) || (isInputElement(target) && target.type === 'file');
|
|
1586
|
+
|
|
1587
|
+
if (isHighRisk) {
|
|
1588
|
+
const approved = await askConfirm(`Action: ${message.action} on "${name || text || target.tagName}"`);
|
|
1589
|
+
if (!approved) {
|
|
1590
|
+
return failAction('E_NEED_USER_CONFIRM', 'User rejected high-risk action');
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (message.action === 'focus') {
|
|
1595
|
+
target.focus({ preventScroll: true });
|
|
1596
|
+
return { ok: true };
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (message.action === 'blur') {
|
|
1600
|
+
target.blur();
|
|
1601
|
+
return { ok: true };
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (message.action === 'scrollIntoView') {
|
|
1605
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1606
|
+
return { ok: true };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (message.action === 'dragDrop') {
|
|
1610
|
+
const from = message.from ? resolveLocator(message.from) : target;
|
|
1611
|
+
const to = message.to ? resolveLocator(message.to) : target;
|
|
1612
|
+
if (!from || !to) {
|
|
1613
|
+
return failAction('E_NOT_FOUND', 'dragDrop endpoints not found');
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const fromPoint = centerPoint(from.getBoundingClientRect());
|
|
1617
|
+
const toPoint = centerPoint(to.getBoundingClientRect());
|
|
1618
|
+
const transfer = new DataTransfer();
|
|
1619
|
+
from.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: transfer }));
|
|
1620
|
+
to.dispatchEvent(
|
|
1621
|
+
new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: transfer, clientX: toPoint.x, clientY: toPoint.y })
|
|
1622
|
+
);
|
|
1623
|
+
to.dispatchEvent(
|
|
1624
|
+
new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer, clientX: toPoint.x, clientY: toPoint.y })
|
|
1625
|
+
);
|
|
1626
|
+
to.dispatchEvent(
|
|
1627
|
+
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer, clientX: toPoint.x, clientY: toPoint.y })
|
|
1628
|
+
);
|
|
1629
|
+
from.dispatchEvent(
|
|
1630
|
+
new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer: transfer, clientX: fromPoint.x, clientY: fromPoint.y })
|
|
1631
|
+
);
|
|
1632
|
+
return { ok: true };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const assessed = assessActionTarget(target, message.action);
|
|
1636
|
+
if (!assessed.ok) {
|
|
1637
|
+
return assessed;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (message.action === 'click') {
|
|
1641
|
+
performClick(target, assessed.point);
|
|
1642
|
+
return { ok: true };
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
if (message.action === 'doubleClick') {
|
|
1646
|
+
performDoubleClick(target, assessed.point);
|
|
1647
|
+
return { ok: true };
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (message.action === 'rightClick') {
|
|
1651
|
+
performRightClick(target, assessed.point);
|
|
1652
|
+
return { ok: true };
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (message.action === 'hover') {
|
|
1656
|
+
dispatchPointer(target, 'pointermove', assessed.point);
|
|
1657
|
+
dispatchMouse(target, 'mousemove', assessed.point);
|
|
1658
|
+
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: assessed.point.x, clientY: assessed.point.y }));
|
|
1659
|
+
return { ok: true };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if (message.action === 'type') {
|
|
1663
|
+
if (!(isEditable(target) || target.isContentEditable)) {
|
|
1664
|
+
return failAction('E_NOT_FOUND', 'Type target is not editable');
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (isEditable(target)) {
|
|
1668
|
+
target.focus({ preventScroll: true });
|
|
1669
|
+
const appendText = message.text ?? '';
|
|
1670
|
+
const nextValue = message.clear ? appendText : `${target.value}${appendText}`;
|
|
1671
|
+
setNativeValue(target, nextValue);
|
|
1672
|
+
dispatchInputEvents(target);
|
|
1673
|
+
} else {
|
|
1674
|
+
insertContentEditableText(target, message.text ?? '', Boolean(message.clear));
|
|
1675
|
+
}
|
|
1676
|
+
return { ok: true };
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (message.action === 'select') {
|
|
1680
|
+
if (!(target instanceof HTMLSelectElement)) {
|
|
1681
|
+
return failAction('E_NOT_FOUND', 'select target is not a <select> element');
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const values = message.values ?? [];
|
|
1685
|
+
if (values.length === 0) {
|
|
1686
|
+
return failAction('E_INVALID_PARAMS', 'values is required');
|
|
1687
|
+
}
|
|
1688
|
+
for (const option of Array.from(target.options)) {
|
|
1689
|
+
option.selected = values.includes(option.value) || values.includes(option.text);
|
|
1690
|
+
}
|
|
1691
|
+
dispatchInputEvents(target);
|
|
1692
|
+
return { ok: true };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (message.action === 'check' || message.action === 'uncheck') {
|
|
1696
|
+
if (!isInputElement(target) || (target.type !== 'checkbox' && target.type !== 'radio')) {
|
|
1697
|
+
return failAction('E_NOT_FOUND', `${message.action} target must be checkbox/radio`);
|
|
1698
|
+
}
|
|
1699
|
+
const desired = message.action === 'check';
|
|
1700
|
+
if (target.checked !== desired) {
|
|
1701
|
+
target.checked = desired;
|
|
1702
|
+
dispatchInputEvents(target);
|
|
1703
|
+
}
|
|
1704
|
+
return { ok: true };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
return failAction('E_NOT_FOUND', `Unsupported action ${message.action}`);
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
return failAction('E_INTERNAL', error instanceof Error ? error.message : String(error));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function waitConditionMet(message: WaitMessage, root: ParentNode): boolean {
|
|
1714
|
+
if (message.mode === 'selector') {
|
|
1715
|
+
return Boolean(querySelectorAcrossOpenShadow(root, message.value));
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
if (message.mode === 'text') {
|
|
1719
|
+
if (root instanceof Document) {
|
|
1720
|
+
const bodyText = root.body?.innerText ?? root.documentElement?.textContent ?? '';
|
|
1721
|
+
return bodyText.includes(message.value);
|
|
1722
|
+
}
|
|
1723
|
+
const text = root.textContent ?? '';
|
|
1724
|
+
return text.includes(message.value);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
return window.location.href.includes(message.value);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function waitFor(message: WaitMessage): Promise<ActionResult> {
|
|
1731
|
+
const timeoutMs = message.timeoutMs ?? 5000;
|
|
1732
|
+
const rootResult = resolveRootForLocator();
|
|
1733
|
+
if (!rootResult.ok) {
|
|
1734
|
+
return { ok: false, error: rootResult.error };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const root = rootResult.root;
|
|
1738
|
+
if (waitConditionMet(message, root)) {
|
|
1739
|
+
return { ok: true };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
return new Promise<ActionResult>((resolve) => {
|
|
1743
|
+
let done = false;
|
|
1744
|
+
const finish = (result: ActionResult): void => {
|
|
1745
|
+
if (done) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
done = true;
|
|
1749
|
+
observer.disconnect();
|
|
1750
|
+
clearInterval(intervalId);
|
|
1751
|
+
clearTimeout(timerId);
|
|
1752
|
+
resolve(result);
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
const check = (): void => {
|
|
1756
|
+
const latestRoot = resolveRootForLocator();
|
|
1757
|
+
if (!latestRoot.ok) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (waitConditionMet(message, latestRoot.root)) {
|
|
1761
|
+
finish({ ok: true });
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
const observer = new MutationObserver(() => {
|
|
1766
|
+
check();
|
|
1767
|
+
});
|
|
1768
|
+
const observationRoot = root instanceof Document ? root.documentElement : (root as Node);
|
|
1769
|
+
if (observationRoot) {
|
|
1770
|
+
observer.observe(observationRoot, {
|
|
1771
|
+
childList: true,
|
|
1772
|
+
subtree: true,
|
|
1773
|
+
attributes: true,
|
|
1774
|
+
characterData: true
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const intervalId = setInterval(() => {
|
|
1779
|
+
check();
|
|
1780
|
+
}, 120);
|
|
1781
|
+
|
|
1782
|
+
const timerId = setTimeout(() => {
|
|
1783
|
+
finish(failAction('E_TIMEOUT', `wait timeout: ${message.mode}=${message.value}`));
|
|
1784
|
+
}, timeoutMs);
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
async function dispatchRpc(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
1789
|
+
switch (method) {
|
|
1790
|
+
case 'page.title':
|
|
1791
|
+
return { title: document.title };
|
|
1792
|
+
case 'page.url':
|
|
1793
|
+
return { url: window.location.href };
|
|
1794
|
+
case 'page.text':
|
|
1795
|
+
return {
|
|
1796
|
+
chunks: pageTextChunks(
|
|
1797
|
+
typeof params.maxChunks === 'number' ? params.maxChunks : 24,
|
|
1798
|
+
typeof params.chunkSize === 'number' ? params.chunkSize : 320
|
|
1799
|
+
)
|
|
1800
|
+
};
|
|
1801
|
+
case 'page.dom':
|
|
1802
|
+
return { summary: domSummary() };
|
|
1803
|
+
case 'page.accessibilityTree':
|
|
1804
|
+
return {
|
|
1805
|
+
nodes: pageAccessibility(typeof params.limit === 'number' ? params.limit : 200)
|
|
1806
|
+
};
|
|
1807
|
+
case 'page.scrollTo': {
|
|
1808
|
+
const x = typeof params.x === 'number' ? params.x : window.scrollX;
|
|
1809
|
+
const y = typeof params.y === 'number' ? params.y : window.scrollY;
|
|
1810
|
+
const behavior = params.behavior === 'smooth' ? 'smooth' : 'auto';
|
|
1811
|
+
window.scrollTo({ left: x, top: y, behavior });
|
|
1812
|
+
return { ok: true, x: window.scrollX, y: window.scrollY };
|
|
1813
|
+
}
|
|
1814
|
+
case 'page.metrics':
|
|
1815
|
+
return pageMetrics();
|
|
1816
|
+
case 'page.viewport':
|
|
1817
|
+
return {
|
|
1818
|
+
width: window.innerWidth,
|
|
1819
|
+
height: window.innerHeight,
|
|
1820
|
+
devicePixelRatio: window.devicePixelRatio
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
case 'element.hover':
|
|
1824
|
+
return handleAction({ type: 'bak.performAction', action: 'hover', locator: params.locator as Locator }).then((res) => {
|
|
1825
|
+
if (!res.ok) {
|
|
1826
|
+
throw res.error;
|
|
1827
|
+
}
|
|
1828
|
+
return { ok: true };
|
|
1829
|
+
});
|
|
1830
|
+
case 'element.doubleClick':
|
|
1831
|
+
return handleAction({
|
|
1832
|
+
type: 'bak.performAction',
|
|
1833
|
+
action: 'doubleClick',
|
|
1834
|
+
locator: params.locator as Locator,
|
|
1835
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1836
|
+
}).then((res) => {
|
|
1837
|
+
if (!res.ok) {
|
|
1838
|
+
throw res.error;
|
|
1839
|
+
}
|
|
1840
|
+
return { ok: true };
|
|
1841
|
+
});
|
|
1842
|
+
case 'element.rightClick':
|
|
1843
|
+
return handleAction({
|
|
1844
|
+
type: 'bak.performAction',
|
|
1845
|
+
action: 'rightClick',
|
|
1846
|
+
locator: params.locator as Locator,
|
|
1847
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1848
|
+
}).then((res) => {
|
|
1849
|
+
if (!res.ok) {
|
|
1850
|
+
throw res.error;
|
|
1851
|
+
}
|
|
1852
|
+
return { ok: true };
|
|
1853
|
+
});
|
|
1854
|
+
case 'element.dragDrop':
|
|
1855
|
+
return handleAction({
|
|
1856
|
+
type: 'bak.performAction',
|
|
1857
|
+
action: 'dragDrop',
|
|
1858
|
+
locator: params.from as Locator,
|
|
1859
|
+
from: params.from as Locator,
|
|
1860
|
+
to: params.to as Locator,
|
|
1861
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1862
|
+
}).then((res) => {
|
|
1863
|
+
if (!res.ok) {
|
|
1864
|
+
throw res.error;
|
|
1865
|
+
}
|
|
1866
|
+
return { ok: true };
|
|
1867
|
+
});
|
|
1868
|
+
case 'element.select':
|
|
1869
|
+
return handleAction({
|
|
1870
|
+
type: 'bak.performAction',
|
|
1871
|
+
action: 'select',
|
|
1872
|
+
locator: params.locator as Locator,
|
|
1873
|
+
values: Array.isArray(params.values) ? (params.values as string[]) : [],
|
|
1874
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1875
|
+
}).then((res) => {
|
|
1876
|
+
if (!res.ok) {
|
|
1877
|
+
throw res.error;
|
|
1878
|
+
}
|
|
1879
|
+
return { ok: true };
|
|
1880
|
+
});
|
|
1881
|
+
case 'element.check':
|
|
1882
|
+
return handleAction({
|
|
1883
|
+
type: 'bak.performAction',
|
|
1884
|
+
action: 'check',
|
|
1885
|
+
locator: params.locator as Locator,
|
|
1886
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1887
|
+
}).then((res) => {
|
|
1888
|
+
if (!res.ok) {
|
|
1889
|
+
throw res.error;
|
|
1890
|
+
}
|
|
1891
|
+
return { ok: true };
|
|
1892
|
+
});
|
|
1893
|
+
case 'element.uncheck':
|
|
1894
|
+
return handleAction({
|
|
1895
|
+
type: 'bak.performAction',
|
|
1896
|
+
action: 'uncheck',
|
|
1897
|
+
locator: params.locator as Locator,
|
|
1898
|
+
requiresConfirm: params.requiresConfirm === true
|
|
1899
|
+
}).then((res) => {
|
|
1900
|
+
if (!res.ok) {
|
|
1901
|
+
throw res.error;
|
|
1902
|
+
}
|
|
1903
|
+
return { ok: true };
|
|
1904
|
+
});
|
|
1905
|
+
case 'element.scrollIntoView':
|
|
1906
|
+
return handleAction({
|
|
1907
|
+
type: 'bak.performAction',
|
|
1908
|
+
action: 'scrollIntoView',
|
|
1909
|
+
locator: params.locator as Locator
|
|
1910
|
+
}).then((res) => {
|
|
1911
|
+
if (!res.ok) {
|
|
1912
|
+
throw res.error;
|
|
1913
|
+
}
|
|
1914
|
+
return { ok: true };
|
|
1915
|
+
});
|
|
1916
|
+
case 'element.focus':
|
|
1917
|
+
return handleAction({
|
|
1918
|
+
type: 'bak.performAction',
|
|
1919
|
+
action: 'focus',
|
|
1920
|
+
locator: params.locator as Locator
|
|
1921
|
+
}).then((res) => {
|
|
1922
|
+
if (!res.ok) {
|
|
1923
|
+
throw res.error;
|
|
1924
|
+
}
|
|
1925
|
+
return { ok: true };
|
|
1926
|
+
});
|
|
1927
|
+
case 'element.blur':
|
|
1928
|
+
return handleAction({
|
|
1929
|
+
type: 'bak.performAction',
|
|
1930
|
+
action: 'blur',
|
|
1931
|
+
locator: params.locator as Locator
|
|
1932
|
+
}).then((res) => {
|
|
1933
|
+
if (!res.ok) {
|
|
1934
|
+
throw res.error;
|
|
1935
|
+
}
|
|
1936
|
+
return { ok: true };
|
|
1937
|
+
});
|
|
1938
|
+
case 'element.get': {
|
|
1939
|
+
const target = resolveLocator(params.locator as Locator);
|
|
1940
|
+
if (!target) {
|
|
1941
|
+
throw notFoundForLocator(params.locator as Locator | undefined, 'element.get target not found');
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const elements = collectElements({}, params.locator as Locator);
|
|
1945
|
+
const eid = buildEid(target);
|
|
1946
|
+
const element = elements.find((item) => item.eid === eid);
|
|
1947
|
+
if (!element) {
|
|
1948
|
+
throw { code: 'E_NOT_FOUND', message: 'element metadata not found' } satisfies ActionError;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
const attributes: Record<string, string> = {};
|
|
1952
|
+
for (const attr of Array.from(target.attributes)) {
|
|
1953
|
+
attributes[attr.name] = attr.value;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
return {
|
|
1957
|
+
element,
|
|
1958
|
+
value: isEditable(target) ? target.value : target.isContentEditable ? target.textContent ?? '' : undefined,
|
|
1959
|
+
checked: isInputElement(target) ? target.checked : undefined,
|
|
1960
|
+
attributes
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
case 'keyboard.press': {
|
|
1965
|
+
const key = String(params.key ?? '').trim();
|
|
1966
|
+
if (!key) {
|
|
1967
|
+
throw { code: 'E_INVALID_PARAMS', message: 'key is required' } satisfies ActionError;
|
|
1968
|
+
}
|
|
1969
|
+
const target = (document.activeElement as HTMLElement | null) ?? document.body;
|
|
1970
|
+
target.dispatchEvent(keyEvent('keydown', key));
|
|
1971
|
+
target.dispatchEvent(keyEvent('keyup', key));
|
|
1972
|
+
return { ok: true };
|
|
1973
|
+
}
|
|
1974
|
+
case 'keyboard.type': {
|
|
1975
|
+
const text = String(params.text ?? '');
|
|
1976
|
+
const delayMs = typeof params.delayMs === 'number' ? Math.max(0, params.delayMs) : 0;
|
|
1977
|
+
const target = (document.activeElement as HTMLElement | null) ?? null;
|
|
1978
|
+
if (!target || !(isEditable(target) || target.isContentEditable)) {
|
|
1979
|
+
throw { code: 'E_NOT_FOUND', message: 'No editable active element for keyboard.type' } satisfies ActionError;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
for (const char of text) {
|
|
1983
|
+
target.dispatchEvent(keyEvent('keydown', char));
|
|
1984
|
+
if (isEditable(target)) {
|
|
1985
|
+
setNativeValue(target, `${target.value}${char}`);
|
|
1986
|
+
dispatchInputEvents(target);
|
|
1987
|
+
} else {
|
|
1988
|
+
insertContentEditableText(target, char, false);
|
|
1989
|
+
}
|
|
1990
|
+
target.dispatchEvent(keyEvent('keyup', char));
|
|
1991
|
+
if (delayMs > 0) {
|
|
1992
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return { ok: true };
|
|
1996
|
+
}
|
|
1997
|
+
case 'keyboard.hotkey': {
|
|
1998
|
+
const keys = Array.isArray(params.keys) ? (params.keys as string[]) : [];
|
|
1999
|
+
if (keys.length === 0) {
|
|
2000
|
+
throw { code: 'E_INVALID_PARAMS', message: 'keys is required' } satisfies ActionError;
|
|
2001
|
+
}
|
|
2002
|
+
const parsed = parseHotkey(keys);
|
|
2003
|
+
const target = (document.activeElement as HTMLElement | null) ?? document.body;
|
|
2004
|
+
target.dispatchEvent(
|
|
2005
|
+
keyEvent('keydown', parsed.key, {
|
|
2006
|
+
ctrlKey: parsed.ctrlKey,
|
|
2007
|
+
altKey: parsed.altKey,
|
|
2008
|
+
shiftKey: parsed.shiftKey,
|
|
2009
|
+
metaKey: parsed.metaKey
|
|
2010
|
+
})
|
|
2011
|
+
);
|
|
2012
|
+
target.dispatchEvent(
|
|
2013
|
+
keyEvent('keyup', parsed.key, {
|
|
2014
|
+
ctrlKey: parsed.ctrlKey,
|
|
2015
|
+
altKey: parsed.altKey,
|
|
2016
|
+
shiftKey: parsed.shiftKey,
|
|
2017
|
+
metaKey: parsed.metaKey
|
|
2018
|
+
})
|
|
2019
|
+
);
|
|
2020
|
+
return { ok: true };
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
case 'mouse.move': {
|
|
2024
|
+
const x = Number(params.x);
|
|
2025
|
+
const y = Number(params.y);
|
|
2026
|
+
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
|
2027
|
+
if (!target) {
|
|
2028
|
+
throw { code: 'E_NOT_FOUND', message: 'mouse.move target not found' } satisfies ActionError;
|
|
2029
|
+
}
|
|
2030
|
+
target.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: x, clientY: y }));
|
|
2031
|
+
return { ok: true };
|
|
2032
|
+
}
|
|
2033
|
+
case 'mouse.click': {
|
|
2034
|
+
const x = Number(params.x);
|
|
2035
|
+
const y = Number(params.y);
|
|
2036
|
+
const button = params.button === 'right' ? 2 : params.button === 'middle' ? 1 : 0;
|
|
2037
|
+
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
|
2038
|
+
if (!target) {
|
|
2039
|
+
throw { code: 'E_NOT_FOUND', message: 'mouse.click target not found' } satisfies ActionError;
|
|
2040
|
+
}
|
|
2041
|
+
target.dispatchEvent(
|
|
2042
|
+
new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: x, clientY: y, button })
|
|
2043
|
+
);
|
|
2044
|
+
target.dispatchEvent(
|
|
2045
|
+
new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: x, clientY: y, button })
|
|
2046
|
+
);
|
|
2047
|
+
target.dispatchEvent(
|
|
2048
|
+
new MouseEvent(button === 2 ? 'contextmenu' : 'click', {
|
|
2049
|
+
bubbles: true,
|
|
2050
|
+
cancelable: true,
|
|
2051
|
+
clientX: x,
|
|
2052
|
+
clientY: y,
|
|
2053
|
+
button
|
|
2054
|
+
})
|
|
2055
|
+
);
|
|
2056
|
+
return { ok: true };
|
|
2057
|
+
}
|
|
2058
|
+
case 'mouse.wheel': {
|
|
2059
|
+
const dx = typeof params.dx === 'number' ? params.dx : 0;
|
|
2060
|
+
const dy = typeof params.dy === 'number' ? params.dy : 120;
|
|
2061
|
+
window.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaX: dx, deltaY: dy }));
|
|
2062
|
+
window.scrollBy({ left: dx, top: dy, behavior: 'auto' });
|
|
2063
|
+
return { ok: true };
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
case 'file.upload': {
|
|
2067
|
+
const target = resolveLocator(params.locator as Locator);
|
|
2068
|
+
if (!target || !isInputElement(target) || target.type !== 'file') {
|
|
2069
|
+
throw notFoundForLocator(params.locator as Locator | undefined, 'file.upload target must be <input type=file>');
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
if (params.requiresConfirm === true) {
|
|
2073
|
+
const targetName = inferName(target) || target.getAttribute('name') || target.id || '<input type=file>';
|
|
2074
|
+
const approved = await askConfirm(`Action: file.upload on "${targetName}"`);
|
|
2075
|
+
if (!approved) {
|
|
2076
|
+
throw { code: 'E_NEED_USER_CONFIRM', message: 'User rejected high-risk action' } satisfies ActionError;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const files = Array.isArray(params.files) ? params.files : [];
|
|
2081
|
+
if (files.length === 0) {
|
|
2082
|
+
throw { code: 'E_INVALID_PARAMS', message: 'files is required' } satisfies ActionError;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const transfer = new DataTransfer();
|
|
2086
|
+
for (const file of files as Array<{ name?: unknown; contentBase64?: unknown; mimeType?: unknown }>) {
|
|
2087
|
+
const name = typeof file.name === 'string' ? file.name : 'upload.bin';
|
|
2088
|
+
const contentBase64 = typeof file.contentBase64 === 'string' ? file.contentBase64 : '';
|
|
2089
|
+
const mimeType = typeof file.mimeType === 'string' ? file.mimeType : 'application/octet-stream';
|
|
2090
|
+
const bytes = Uint8Array.from(atob(contentBase64), (char) => char.charCodeAt(0));
|
|
2091
|
+
transfer.items.add(new File([bytes], name, { type: mimeType }));
|
|
2092
|
+
}
|
|
2093
|
+
target.files = transfer.files;
|
|
2094
|
+
dispatchInputEvents(target);
|
|
2095
|
+
return { ok: true, fileCount: transfer.files.length };
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
case 'context.enterFrame': {
|
|
2099
|
+
if (params.reset === true) {
|
|
2100
|
+
contextState.framePath = [];
|
|
2101
|
+
}
|
|
2102
|
+
const framePath = Array.isArray(params.framePath) ? params.framePath.map(String) : [];
|
|
2103
|
+
if (framePath.length === 0 && params.locator && typeof (params.locator as Locator).css === 'string') {
|
|
2104
|
+
framePath.push((params.locator as Locator).css!);
|
|
2105
|
+
}
|
|
2106
|
+
if (framePath.length === 0) {
|
|
2107
|
+
throw { code: 'E_INVALID_PARAMS', message: 'framePath or locator.css is required' } satisfies ActionError;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
const candidate = [...contextState.framePath, ...framePath];
|
|
2111
|
+
const check = resolveFrameDocument(candidate);
|
|
2112
|
+
if (!check.ok) {
|
|
2113
|
+
throw check.error;
|
|
2114
|
+
}
|
|
2115
|
+
contextState.framePath = candidate;
|
|
2116
|
+
return { ok: true, frameDepth: contextState.framePath.length, framePath: [...contextState.framePath] };
|
|
2117
|
+
}
|
|
2118
|
+
case 'context.exitFrame': {
|
|
2119
|
+
if (params.reset === true) {
|
|
2120
|
+
contextState.framePath = [];
|
|
2121
|
+
} else {
|
|
2122
|
+
const levels = typeof params.levels === 'number' ? Math.max(1, Math.floor(params.levels)) : 1;
|
|
2123
|
+
contextState.framePath = contextState.framePath.slice(0, Math.max(0, contextState.framePath.length - levels));
|
|
2124
|
+
}
|
|
2125
|
+
return { ok: true, frameDepth: contextState.framePath.length, framePath: [...contextState.framePath] };
|
|
2126
|
+
}
|
|
2127
|
+
case 'context.enterShadow': {
|
|
2128
|
+
if (params.reset === true) {
|
|
2129
|
+
contextState.shadowPath = [];
|
|
2130
|
+
}
|
|
2131
|
+
const hostSelectors = Array.isArray(params.hostSelectors) ? params.hostSelectors.map(String) : [];
|
|
2132
|
+
if (hostSelectors.length === 0 && params.locator && typeof (params.locator as Locator).css === 'string') {
|
|
2133
|
+
hostSelectors.push((params.locator as Locator).css!);
|
|
2134
|
+
}
|
|
2135
|
+
if (hostSelectors.length === 0) {
|
|
2136
|
+
throw { code: 'E_INVALID_PARAMS', message: 'hostSelectors or locator.css is required' } satisfies ActionError;
|
|
2137
|
+
}
|
|
2138
|
+
const rootResult = resolveRootForLocator();
|
|
2139
|
+
if (!rootResult.ok) {
|
|
2140
|
+
throw rootResult.error;
|
|
2141
|
+
}
|
|
2142
|
+
const candidate = [...contextState.shadowPath, ...hostSelectors];
|
|
2143
|
+
const check = resolveShadowRoot(rootResult.root, candidate);
|
|
2144
|
+
if (!check.ok) {
|
|
2145
|
+
throw check.error;
|
|
2146
|
+
}
|
|
2147
|
+
contextState.shadowPath = candidate;
|
|
2148
|
+
return { ok: true, shadowDepth: contextState.shadowPath.length, shadowPath: [...contextState.shadowPath] };
|
|
2149
|
+
}
|
|
2150
|
+
case 'context.exitShadow': {
|
|
2151
|
+
if (params.reset === true) {
|
|
2152
|
+
contextState.shadowPath = [];
|
|
2153
|
+
} else {
|
|
2154
|
+
const levels = typeof params.levels === 'number' ? Math.max(1, Math.floor(params.levels)) : 1;
|
|
2155
|
+
contextState.shadowPath = contextState.shadowPath.slice(0, Math.max(0, contextState.shadowPath.length - levels));
|
|
2156
|
+
}
|
|
2157
|
+
return { ok: true, shadowDepth: contextState.shadowPath.length, shadowPath: [...contextState.shadowPath] };
|
|
2158
|
+
}
|
|
2159
|
+
case 'context.reset':
|
|
2160
|
+
contextState.framePath = [];
|
|
2161
|
+
contextState.shadowPath = [];
|
|
2162
|
+
return { ok: true, frameDepth: 0, shadowDepth: 0 };
|
|
2163
|
+
|
|
2164
|
+
case 'network.list':
|
|
2165
|
+
return { entries: filterNetworkEntries(params) };
|
|
2166
|
+
case 'network.get': {
|
|
2167
|
+
const id = String(params.id ?? '');
|
|
2168
|
+
const found = networkSnapshotEntries().find((entry) => entry.id === id);
|
|
2169
|
+
if (!found) {
|
|
2170
|
+
throw { code: 'E_NOT_FOUND', message: `network entry not found: ${id}` } satisfies ActionError;
|
|
2171
|
+
}
|
|
2172
|
+
return { entry: found };
|
|
2173
|
+
}
|
|
2174
|
+
case 'network.waitFor':
|
|
2175
|
+
return { entry: await waitForNetwork(params) };
|
|
2176
|
+
case 'network.clear':
|
|
2177
|
+
networkEntries.length = 0;
|
|
2178
|
+
performanceBaselineMs = performance.now();
|
|
2179
|
+
return { ok: true };
|
|
2180
|
+
|
|
2181
|
+
case 'debug.dumpState': {
|
|
2182
|
+
const consoleLimit = typeof params.consoleLimit === 'number' ? Math.max(1, Math.floor(params.consoleLimit)) : 80;
|
|
2183
|
+
const networkLimit = typeof params.networkLimit === 'number' ? Math.max(1, Math.floor(params.networkLimit)) : 80;
|
|
2184
|
+
const includeAccessibility = params.includeAccessibility === true;
|
|
2185
|
+
return {
|
|
2186
|
+
url: window.location.href,
|
|
2187
|
+
title: document.title,
|
|
2188
|
+
context: {
|
|
2189
|
+
framePath: [...contextState.framePath],
|
|
2190
|
+
shadowPath: [...contextState.shadowPath]
|
|
2191
|
+
},
|
|
2192
|
+
dom: domSummary(),
|
|
2193
|
+
text: pageTextChunks(12, 260),
|
|
2194
|
+
console: consoleEntries.slice(-consoleLimit),
|
|
2195
|
+
network: filterNetworkEntries({ limit: networkLimit }),
|
|
2196
|
+
accessibility: includeAccessibility ? pageAccessibility(200) : undefined
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
default:
|
|
2200
|
+
throw { code: 'E_NOT_FOUND', message: `Unsupported content RPC method: ${method}` } satisfies ActionError;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => {
|
|
2205
|
+
if (typeof message !== 'object' || message === null || !('type' in message)) {
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const typed = message as { type: string };
|
|
2210
|
+
|
|
2211
|
+
if (typed.type === 'bak.collectElements') {
|
|
2212
|
+
const request = message as CollectElementsMessage;
|
|
2213
|
+
sendResponse({ elements: collectElements({ debugRichText: Boolean(request.debugRichText) }) });
|
|
2214
|
+
return false;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (typed.type === 'bak.getConsole') {
|
|
2218
|
+
const limit = Number((message as { limit?: number }).limit ?? 50);
|
|
2219
|
+
sendResponse({ entries: consoleEntries.slice(-limit) });
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
if (typed.type === 'bak.performAction') {
|
|
2224
|
+
void handleAction(message as ActionMessage).then(sendResponse);
|
|
2225
|
+
return true;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
if (typed.type === 'bak.waitFor') {
|
|
2229
|
+
void waitFor(message as WaitMessage).then(sendResponse);
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (typed.type === 'bak.selectCandidate') {
|
|
2234
|
+
void pickCandidate((message as CandidateMessage).candidates).then((selectedEid) => {
|
|
2235
|
+
if (!selectedEid) {
|
|
2236
|
+
sendResponse({ ok: false, error: { code: 'E_NEED_USER_CONFIRM', message: 'No candidate selected' } });
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
sendResponse({ ok: true, selectedEid });
|
|
2240
|
+
});
|
|
2241
|
+
return true;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
if (typed.type === 'bak.rpc') {
|
|
2245
|
+
const request = message as RpcMessage;
|
|
2246
|
+
void dispatchRpc(request.method, request.params ?? {})
|
|
2247
|
+
.then((result) => {
|
|
2248
|
+
const payload: RpcEnvelope = { ok: true, result };
|
|
2249
|
+
sendResponse(payload);
|
|
2250
|
+
})
|
|
2251
|
+
.catch((error) => {
|
|
2252
|
+
const normalized =
|
|
2253
|
+
typeof error === 'object' && error !== null && 'code' in error
|
|
2254
|
+
? (error as ActionError)
|
|
2255
|
+
: { code: 'E_INTERNAL', message: error instanceof Error ? error.message : String(error) };
|
|
2256
|
+
const payload: RpcEnvelope = { ok: false, error: normalized };
|
|
2257
|
+
sendResponse(payload);
|
|
2258
|
+
});
|
|
2259
|
+
return true;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
return false;
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
ensureOverlayRoot();
|
|
2266
|
+
|
|
2267
|
+
|