mecord-connect 1.0.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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { OperatorError } from '../core/errors.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 8_000;
|
|
4
|
+
const MAX_CDP_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export function assertLoopbackDebuggerUrl(raw ) {
|
|
25
|
+
let url ;
|
|
26
|
+
try { url = new URL(raw); } catch { throw new OperatorError('UNSAFE_CDP_WEBSOCKET', 'Invalid DevTools WebSocket URL.'); }
|
|
27
|
+
if (!['ws:', 'wss:'].includes(url.protocol) || !['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)) {
|
|
28
|
+
throw new OperatorError('UNSAFE_CDP_WEBSOCKET', 'DevTools WebSocket endpoint must be loopback-only.');
|
|
29
|
+
}
|
|
30
|
+
if (url.username || url.password || url.hash) {
|
|
31
|
+
throw new OperatorError('UNSAFE_CDP_WEBSOCKET', 'DevTools WebSocket endpoint must not contain credentials or a fragment.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameWebSocketDestination(expectedRaw , actualRaw ) {
|
|
36
|
+
try { return new URL(expectedRaw).toString() === new URL(actualRaw).toString(); } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class CdpConnection {
|
|
40
|
+
targetId ;
|
|
41
|
+
url ;
|
|
42
|
+
#socket ;
|
|
43
|
+
#nextId = 1;
|
|
44
|
+
#pending = new Map ();
|
|
45
|
+
#listeners = new Map ();
|
|
46
|
+
#ready ;
|
|
47
|
+
#closed = false;
|
|
48
|
+
|
|
49
|
+
constructor(targetId , url ) {
|
|
50
|
+
assertLoopbackDebuggerUrl(url);
|
|
51
|
+
this.targetId = targetId;
|
|
52
|
+
this.url = url;
|
|
53
|
+
this.#socket = new WebSocket(url);
|
|
54
|
+
this.#ready = new Promise ((resolve, reject) => {
|
|
55
|
+
let settled = false;
|
|
56
|
+
const finish = (callback ) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
callback();
|
|
61
|
+
};
|
|
62
|
+
const timeout = setTimeout(() => finish(() => {
|
|
63
|
+
this.#closed = true;
|
|
64
|
+
try { this.#socket.close(); } catch { /* timeout is already authoritative */ }
|
|
65
|
+
reject(new OperatorError('CDP_CONNECT_TIMEOUT', 'Timed out connecting to browser target.', { retryable: true }));
|
|
66
|
+
}), 4_000);
|
|
67
|
+
this.#socket.addEventListener('open', () => finish(() => {
|
|
68
|
+
if (!sameWebSocketDestination(url, this.#socket.url)) {
|
|
69
|
+
this.#closed = true;
|
|
70
|
+
try { this.#socket.close(); } catch { /* destination mismatch is already authoritative */ }
|
|
71
|
+
reject(new OperatorError('CDP_WEBSOCKET_DESTINATION_CHANGED', 'Opened DevTools WebSocket destination differs from the authorized endpoint.', { retryable: false }));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
resolve();
|
|
75
|
+
}), { once: true });
|
|
76
|
+
this.#socket.addEventListener('error', () => finish(() => {
|
|
77
|
+
this.#closed = true;
|
|
78
|
+
try { this.#socket.close(); } catch { /* connection error is already authoritative */ }
|
|
79
|
+
reject(new OperatorError('CDP_CONNECT_FAILED', 'Failed to connect to browser target.', { retryable: true }));
|
|
80
|
+
}), { once: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.#socket.addEventListener('message', (event) => {
|
|
84
|
+
try {
|
|
85
|
+
const raw = typeof event.data === 'string' ? event.data : String(event.data);
|
|
86
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
|
|
87
|
+
this.close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const message = JSON.parse(raw)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
;
|
|
98
|
+
if (typeof message.id === 'number') {
|
|
99
|
+
const pending = this.#pending.get(message.id);
|
|
100
|
+
if (!pending) return;
|
|
101
|
+
clearTimeout(pending.timer);
|
|
102
|
+
this.#pending.delete(message.id);
|
|
103
|
+
if (message.error) {
|
|
104
|
+
pending.reject(new OperatorError('CDP_COMMAND_FAILED', message.error.message ?? 'CDP command failed.', { retryable: false, details: { code: message.error.code } }));
|
|
105
|
+
} else {
|
|
106
|
+
pending.resolve(message.result ?? {});
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (message.method) {
|
|
111
|
+
for (const listener of this.#listeners.get(message.method) ?? []) listener(message.params ?? {}, message.sessionId);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore malformed browser event payloads. A later command/postcondition will expose the failure.
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
this.#socket.addEventListener('close', () => {
|
|
119
|
+
this.#closed = true;
|
|
120
|
+
const error = new OperatorError('CDP_CONNECTION_CLOSED', 'Browser target connection closed.', { retryable: true });
|
|
121
|
+
for (const pending of this.#pending.values()) {
|
|
122
|
+
clearTimeout(pending.timer);
|
|
123
|
+
pending.reject(error);
|
|
124
|
+
}
|
|
125
|
+
this.#pending.clear();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get closed() { return this.#closed; }
|
|
130
|
+
|
|
131
|
+
async send(method , params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
132
|
+
return this.#send(method, params, timeoutMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async sendInSession(sessionId , method , params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
136
|
+
if (!sessionId || sessionId.length > 256) {
|
|
137
|
+
throw new OperatorError('INVALID_CDP_SESSION', 'Flattened CDP session id must contain 1-256 characters.');
|
|
138
|
+
}
|
|
139
|
+
return this.#send(method, params, timeoutMs, sessionId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #send(method , params , timeoutMs , sessionId ) {
|
|
143
|
+
await this.#ready;
|
|
144
|
+
if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) {
|
|
145
|
+
throw new OperatorError('CDP_CONNECTION_CLOSED', 'Browser target connection is not open.', { retryable: true });
|
|
146
|
+
}
|
|
147
|
+
const id = this.#nextId++;
|
|
148
|
+
return await new Promise ((resolve, reject) => {
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this.#pending.delete(id);
|
|
151
|
+
reject(new OperatorError('CDP_COMMAND_TIMEOUT', `${method} timed out.`, { retryable: true }));
|
|
152
|
+
}, timeoutMs);
|
|
153
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
154
|
+
this.#socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
on(method , listener ) {
|
|
159
|
+
const bucket = this.#listeners.get(method) ?? new Set ();
|
|
160
|
+
bucket.add(listener);
|
|
161
|
+
this.#listeners.set(method, bucket);
|
|
162
|
+
return () => {
|
|
163
|
+
bucket.delete(listener);
|
|
164
|
+
if (bucket.size === 0) this.#listeners.delete(method);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
close() {
|
|
169
|
+
if (this.#closed) return;
|
|
170
|
+
this.#closed = true;
|
|
171
|
+
try { this.#socket.close(); } catch { /* noop */ }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class CdpSessionManager {
|
|
176
|
+
#sessions = new Map ();
|
|
177
|
+
|
|
178
|
+
get(target ) {
|
|
179
|
+
if (!target.webSocketDebuggerUrl) {
|
|
180
|
+
throw new OperatorError('CDP_TARGET_UNAVAILABLE', 'Target does not expose a DevTools WebSocket endpoint.', { retryable: true });
|
|
181
|
+
}
|
|
182
|
+
const existing = this.#sessions.get(target.id);
|
|
183
|
+
if (existing && !existing.closed && existing.url === target.webSocketDebuggerUrl) return existing;
|
|
184
|
+
existing?.close();
|
|
185
|
+
const created = new CdpConnection(target.id, target.webSocketDebuggerUrl);
|
|
186
|
+
this.#sessions.set(target.id, created);
|
|
187
|
+
return created;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
forget(targetId ) {
|
|
191
|
+
this.#sessions.get(targetId)?.close();
|
|
192
|
+
this.#sessions.delete(targetId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
closeAll() {
|
|
196
|
+
for (const session of this.#sessions.values()) session.close();
|
|
197
|
+
this.#sessions.clear();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { OperatorError } from '../core/errors.js';
|
|
2
|
+
|
|
3
|
+
import { interactionFunction, semanticSnapshotFunction, unwrapRuntimeValue } from './browser-cdp-page.js';
|
|
4
|
+
|
|
5
|
+
const MAX_OOPIF_SESSIONS = 16;
|
|
6
|
+
const MAX_OOPIF_DEPTH = 4;
|
|
7
|
+
const ATTACH_SETTLE_MS = 25;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
const AUTO_ATTACH_ON = {
|
|
27
|
+
autoAttach: true,
|
|
28
|
+
waitForDebuggerOnStart: false,
|
|
29
|
+
flatten: true
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const AUTO_ATTACH_OFF = {
|
|
33
|
+
autoAttach: false,
|
|
34
|
+
waitForDebuggerOnStart: false,
|
|
35
|
+
flatten: true
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function delay(ms ) {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function attachOopifSessions(session ) {
|
|
43
|
+
const frames = new Map ();
|
|
44
|
+
const depthBySession = new Map ();
|
|
45
|
+
const armed = new Set ();
|
|
46
|
+
const offAttached = session.on('Target.attachedToTarget', (params, parentSessionId) => {
|
|
47
|
+
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
|
|
48
|
+
const targetInfo = params.targetInfo && typeof params.targetInfo === 'object' ? params.targetInfo : undefined;
|
|
49
|
+
const type = typeof targetInfo?.type === 'string' ? targetInfo.type : '';
|
|
50
|
+
if (!sessionId || type !== 'iframe' || frames.has(sessionId) || frames.size >= MAX_OOPIF_SESSIONS) return;
|
|
51
|
+
const parentDepth = parentSessionId ? depthBySession.get(parentSessionId) ?? 0 : 0;
|
|
52
|
+
const depth = parentDepth + 1;
|
|
53
|
+
if (depth > MAX_OOPIF_DEPTH) return;
|
|
54
|
+
const frame = {
|
|
55
|
+
sessionId,
|
|
56
|
+
targetId: typeof targetInfo?.targetId === 'string' ? targetInfo.targetId : '',
|
|
57
|
+
url: typeof targetInfo?.url === 'string' ? targetInfo.url.slice(0, 2000) : '',
|
|
58
|
+
depth
|
|
59
|
+
};
|
|
60
|
+
frames.set(sessionId, frame);
|
|
61
|
+
depthBySession.set(sessionId, depth);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await session.send('Target.setAutoAttach', AUTO_ATTACH_ON);
|
|
66
|
+
} catch {
|
|
67
|
+
offAttached();
|
|
68
|
+
return { frames: [], async stop() { /* target does not expose child-target auto-attach */ } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let round = 0; round < MAX_OOPIF_DEPTH; round += 1) {
|
|
72
|
+
await delay(ATTACH_SETTLE_MS);
|
|
73
|
+
const toArm = [...frames.values()].filter((frame) => frame.depth < MAX_OOPIF_DEPTH && !armed.has(frame.sessionId));
|
|
74
|
+
if (toArm.length === 0 && round > 0) break;
|
|
75
|
+
await Promise.allSettled(toArm.map(async (frame) => {
|
|
76
|
+
armed.add(frame.sessionId);
|
|
77
|
+
await session.sendInSession(frame.sessionId, 'Target.setAutoAttach', AUTO_ATTACH_ON);
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
await delay(ATTACH_SETTLE_MS);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
frames: [...frames.values()].sort((a, b) => a.depth - b.depth || a.targetId.localeCompare(b.targetId)),
|
|
84
|
+
async stop() {
|
|
85
|
+
offAttached();
|
|
86
|
+
const deepestFirst = [...frames.values()].sort((a, b) => b.depth - a.depth);
|
|
87
|
+
await Promise.allSettled(deepestFirst.map((frame) => session.sendInSession(frame.sessionId, 'Target.setAutoAttach', AUTO_ATTACH_OFF)));
|
|
88
|
+
await Promise.allSettled([session.send('Target.setAutoAttach', AUTO_ATTACH_OFF)]);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function evaluate(
|
|
94
|
+
session ,
|
|
95
|
+
context ,
|
|
96
|
+
expression ,
|
|
97
|
+
userGesture = false
|
|
98
|
+
) {
|
|
99
|
+
const params = { expression, returnByValue: true, awaitPromise: true, userGesture };
|
|
100
|
+
return context.frame
|
|
101
|
+
? session.sendInSession(context.frame.sessionId, 'Runtime.evaluate', params)
|
|
102
|
+
: session.send('Runtime.evaluate', params);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function semanticLocatorFunction(target ) {
|
|
106
|
+
const trim = (value ) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
107
|
+
const deepQuery = (selector , max = 1000) => {
|
|
108
|
+
const found = [];
|
|
109
|
+
const seenScopes = new Set ();
|
|
110
|
+
let scanned = 0;
|
|
111
|
+
const visit = (scope , frameDepth , shadowDepth ) => {
|
|
112
|
+
if (!scope || seenScopes.has(scope) || found.length >= max || scanned >= 5000) return;
|
|
113
|
+
seenScopes.add(scope);
|
|
114
|
+
let elements = [];
|
|
115
|
+
try { elements = Array.from(scope.querySelectorAll('*')).slice(0, 2500); } catch { return; }
|
|
116
|
+
for (const element of elements) {
|
|
117
|
+
if (found.length >= max || scanned++ >= 5000) break;
|
|
118
|
+
try { if (element.matches(selector)) found.push({ element, context: { frameDepth, shadowDepth } }); } catch { /* invalid selector becomes no match */ }
|
|
119
|
+
const shadow = (element ).shadowRoot;
|
|
120
|
+
if (shadow && shadowDepth < 8) visit(shadow, frameDepth, shadowDepth + 1);
|
|
121
|
+
if (element.tagName === 'IFRAME' && frameDepth < 4) {
|
|
122
|
+
try {
|
|
123
|
+
const frameDocument = (element ).contentDocument;
|
|
124
|
+
if (frameDocument?.documentElement) visit(frameDocument, frameDepth + 1, shadowDepth);
|
|
125
|
+
} catch { /* cross-origin iframe is handled through an attached CDP target */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
visit(document, 0, 0);
|
|
130
|
+
return found;
|
|
131
|
+
};
|
|
132
|
+
const nameOf = (element ) => {
|
|
133
|
+
const aria = element.getAttribute('aria-label');
|
|
134
|
+
if (aria) return trim(aria);
|
|
135
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
136
|
+
if (labelledBy) {
|
|
137
|
+
const root = element.getRootNode() ;
|
|
138
|
+
const labels = labelledBy.split(/\s+/).map((id) => (root ).getElementById?.(id)?.textContent ?? '').filter(Boolean);
|
|
139
|
+
if (labels.length) return trim(labels.join(' '));
|
|
140
|
+
}
|
|
141
|
+
const control = element ;
|
|
142
|
+
if (control.labels?.length) return trim(Array.from(control.labels).map((label) => label.textContent).join(' '));
|
|
143
|
+
const nativeText = ['BUTTON', 'SUMMARY', 'A', 'OPTION'].includes(element.tagName) ? trim(element.textContent) : '';
|
|
144
|
+
return trim(
|
|
145
|
+
element.getAttribute('placeholder')
|
|
146
|
+
|| nativeText
|
|
147
|
+
|| element.getAttribute('title')
|
|
148
|
+
|| element.getAttribute('name')
|
|
149
|
+
|| element.id
|
|
150
|
+
|| element.textContent
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
const roleOf = (element ) => {
|
|
154
|
+
const explicit = trim(element.getAttribute('role')).toLowerCase();
|
|
155
|
+
if (explicit) return explicit;
|
|
156
|
+
const tag = element.tagName;
|
|
157
|
+
if (tag === 'A' && element.hasAttribute('href')) return 'link';
|
|
158
|
+
if (tag === 'BUTTON' || tag === 'SUMMARY') return 'button';
|
|
159
|
+
if (tag === 'TEXTAREA') return 'textbox';
|
|
160
|
+
if (tag === 'SELECT') return 'combobox';
|
|
161
|
+
if (tag === 'OPTION') return 'option';
|
|
162
|
+
if (tag === 'INPUT') {
|
|
163
|
+
const type = trim(element.getAttribute('type') || 'text').toLowerCase();
|
|
164
|
+
if (type === 'checkbox') return 'checkbox';
|
|
165
|
+
if (type === 'radio') return 'radio';
|
|
166
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button';
|
|
167
|
+
if (type === 'search') return 'searchbox';
|
|
168
|
+
return 'textbox';
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
};
|
|
172
|
+
const visible = (element ) => {
|
|
173
|
+
const view = element.ownerDocument?.defaultView;
|
|
174
|
+
const style = view?.getComputedStyle?.(element);
|
|
175
|
+
const rect = (element ).getBoundingClientRect?.();
|
|
176
|
+
if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
|
|
177
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
178
|
+
};
|
|
179
|
+
const selector = target.css || 'button,a[href],input,textarea,select,option,summary,[role],[contenteditable="true"]';
|
|
180
|
+
const matches = deepQuery(selector, 1000).filter(({ element }) => {
|
|
181
|
+
if (!visible(element)) return false;
|
|
182
|
+
if (target.text && !trim(element.textContent).toLowerCase().includes(target.text.toLowerCase())) return false;
|
|
183
|
+
if (target.role && roleOf(element) !== target.role.toLowerCase()) return false;
|
|
184
|
+
if (target.name && nameOf(element).toLowerCase() !== target.name.toLowerCase()) return false;
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
count: matches.length,
|
|
189
|
+
matches: matches.slice(0, 3).map(({ element, context }) => ({
|
|
190
|
+
tag: element.tagName.toLowerCase(),
|
|
191
|
+
role: roleOf(element),
|
|
192
|
+
name: nameOf(element),
|
|
193
|
+
context
|
|
194
|
+
}))
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function inspectOopifFrames(session )
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
{
|
|
206
|
+
const scope = await attachOopifSessions(session);
|
|
207
|
+
try {
|
|
208
|
+
const output = [];
|
|
209
|
+
for (const frame of scope.frames) {
|
|
210
|
+
try {
|
|
211
|
+
await Promise.allSettled([
|
|
212
|
+
session.sendInSession(frame.sessionId, 'Runtime.enable'),
|
|
213
|
+
session.sendInSession(frame.sessionId, 'Accessibility.enable')
|
|
214
|
+
]);
|
|
215
|
+
const [identityResult, semanticResult] = await Promise.all([
|
|
216
|
+
session.sendInSession(frame.sessionId, 'Runtime.evaluate', {
|
|
217
|
+
expression: '({url:location.href,title:document.title,readyState:document.readyState})',
|
|
218
|
+
returnByValue: true
|
|
219
|
+
}),
|
|
220
|
+
session.sendInSession(frame.sessionId, 'Runtime.evaluate', {
|
|
221
|
+
expression: `(${semanticSnapshotFunction.toString()})()`,
|
|
222
|
+
returnByValue: true
|
|
223
|
+
})
|
|
224
|
+
]);
|
|
225
|
+
const identity = unwrapRuntimeValue(identityResult) ;
|
|
226
|
+
output.push({
|
|
227
|
+
targetId: frame.targetId,
|
|
228
|
+
url: typeof identity?.url === 'string' ? identity.url.slice(0, 2000) : frame.url,
|
|
229
|
+
depth: frame.depth,
|
|
230
|
+
title: typeof identity?.title === 'string' ? identity.title.slice(0, 500) : '',
|
|
231
|
+
readyState: typeof identity?.readyState === 'string' ? identity.readyState : '',
|
|
232
|
+
semantic: unwrapRuntimeValue(semanticResult) ?? null
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
// A frame may disappear during navigation. Omit it rather than failing the bounded parent snapshot.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return output;
|
|
239
|
+
} finally {
|
|
240
|
+
await scope.stop();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function performSemanticInteraction(
|
|
245
|
+
session ,
|
|
246
|
+
input
|
|
247
|
+
) {
|
|
248
|
+
const scope = await attachOopifSessions(session);
|
|
249
|
+
try {
|
|
250
|
+
const contexts = [{ kind: 'main' }, ...scope.frames.map((frame) => ({ kind: 'oopif', frame }))];
|
|
251
|
+
const locateExpression = `(${semanticLocatorFunction.toString()})(${JSON.stringify(input.target)})`;
|
|
252
|
+
const matches = [];
|
|
253
|
+
|
|
254
|
+
for (const context of contexts) {
|
|
255
|
+
try {
|
|
256
|
+
const located = unwrapRuntimeValue(await evaluate(session, context, locateExpression)) ;
|
|
257
|
+
const count = typeof located?.count === 'number' ? located.count : 0;
|
|
258
|
+
if (count > 0) matches.push({ context, count, samples: located?.matches });
|
|
259
|
+
} catch {
|
|
260
|
+
// Cross-origin frame may disappear or deny execution while the page is changing; continue bounded search.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const totalMatches = matches.reduce((sum, match) => sum + match.count, 0);
|
|
265
|
+
if (totalMatches === 0) {
|
|
266
|
+
throw new OperatorError('BROWSER_ELEMENT_NOT_FOUND', 'No matching semantic element was found.', { retryable: false, details: { target: input.target } });
|
|
267
|
+
}
|
|
268
|
+
if (totalMatches > 1) {
|
|
269
|
+
throw new OperatorError('BROWSER_AMBIGUOUS_ELEMENT', 'Semantic browser target matched multiple elements across page/frame contexts; narrow the selector.', {
|
|
270
|
+
retryable: false,
|
|
271
|
+
details: { target: input.target, totalMatches, contexts: matches.map((match) => ({ count: match.count, frameTargetId: match.context.frame?.targetId, samples: match.samples })) }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const chosen = matches[0] .context;
|
|
276
|
+
const expression = `(${interactionFunction.toString()})(${JSON.stringify(input)})`;
|
|
277
|
+
const value = unwrapRuntimeValue(await evaluate(session, chosen, expression, true));
|
|
278
|
+
if (!value || typeof value !== 'object') {
|
|
279
|
+
throw new OperatorError('BROWSER_INTERACTION_FAILED', 'Browser interaction returned no semantic result.', { retryable: true });
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
value: value ,
|
|
283
|
+
...(chosen.frame ? { frame: { targetId: chosen.frame.targetId, url: chosen.frame.url, depth: chosen.frame.depth } } : {})
|
|
284
|
+
};
|
|
285
|
+
} finally {
|
|
286
|
+
await scope.stop();
|
|
287
|
+
}
|
|
288
|
+
}
|