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,430 @@
|
|
|
1
|
+
|
|
2
|
+
import { evidence } from '../core/evidence.js';
|
|
3
|
+
import { OperatorError } from '../core/errors.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const MAX_AX_NODES = 160;
|
|
7
|
+
|
|
8
|
+
export function assertLoopbackEndpoint(endpoint ) {
|
|
9
|
+
if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(endpoint.hostname)) {
|
|
10
|
+
throw new OperatorError('UNSAFE_CDP_ENDPOINT', 'CDP provider only permits loopback endpoints.');
|
|
11
|
+
}
|
|
12
|
+
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
|
13
|
+
throw new OperatorError('UNSAFE_CDP_ENDPOINT', 'CDP discovery endpoint must use HTTP(S).');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function compactTab(tab ) {
|
|
18
|
+
return { id: tab.id, type: tab.type, title: tab.title, url: tab.url };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function requireTarget(targets , id ) {
|
|
22
|
+
const target = targets.find((item) => item.id === id);
|
|
23
|
+
if (!target) throw new OperatorError('BROWSER_TARGET_NOT_FOUND', `Browser target ${id} was not found.`, { retryable: true });
|
|
24
|
+
return target;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function requirePageTarget(targets ) {
|
|
28
|
+
const target = targets.find((item) => item.type === 'page');
|
|
29
|
+
if (!target) throw new OperatorError('BROWSER_TARGET_NOT_FOUND', 'No browser page target is available.', { retryable: true });
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateNavigationUrl(raw ) {
|
|
34
|
+
let url ;
|
|
35
|
+
try { url = new URL(raw); } catch { throw new OperatorError('INVALID_URL', 'A valid absolute URL is required.'); }
|
|
36
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new OperatorError('URL_SCHEME_DENIED', 'Only HTTP(S) browser navigation is allowed.');
|
|
37
|
+
if (url.username || url.password) throw new OperatorError('URL_CREDENTIALS_DENIED', 'Credentials must not be embedded in browser URLs.');
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sameDestination(actualRaw , expectedRaw ) {
|
|
42
|
+
try {
|
|
43
|
+
const actual = new URL(actualRaw);
|
|
44
|
+
const expected = new URL(expectedRaw);
|
|
45
|
+
actual.hash = '';
|
|
46
|
+
expected.hash = '';
|
|
47
|
+
return actual.origin === expected.origin && actual.search === expected.search && (actual.pathname === expected.pathname || actual.pathname.replace(/\/$/, '') === expected.pathname.replace(/\/$/, ''));
|
|
48
|
+
} catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function unwrapRuntimeValue(result ) {
|
|
52
|
+
const remote = result.result;
|
|
53
|
+
return remote && typeof remote === 'object' ? (remote ).value : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function pageIdentity(session ) {
|
|
57
|
+
const result = await session.send('Runtime.evaluate', {
|
|
58
|
+
expression: '({url:location.href,title:document.title,readyState:document.readyState})',
|
|
59
|
+
returnByValue: true
|
|
60
|
+
});
|
|
61
|
+
const value = unwrapRuntimeValue(result) ;
|
|
62
|
+
return {
|
|
63
|
+
url: typeof value?.url === 'string' ? value.url : '',
|
|
64
|
+
title: typeof value?.title === 'string' ? value.title : '',
|
|
65
|
+
readyState: typeof value?.readyState === 'string' ? value.readyState : ''
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function waitForReadyState(session , timeoutMs , signal ) {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
throwIfAborted(signal);
|
|
73
|
+
const state = await pageIdentity(session);
|
|
74
|
+
throwIfAborted(signal);
|
|
75
|
+
if (state.readyState === 'interactive' || state.readyState === 'complete') return;
|
|
76
|
+
await delay(75, signal);
|
|
77
|
+
}
|
|
78
|
+
throw new OperatorError('BROWSER_READY_TIMEOUT', 'Timed out waiting for the page to become ready.', { retryable: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function settleAfterInteraction(session , signal ) {
|
|
82
|
+
await delay(50, signal);
|
|
83
|
+
try { await waitForReadyState(session, 2_000, signal); } catch (error) {
|
|
84
|
+
if (!(error instanceof OperatorError) || error.code !== 'BROWSER_READY_TIMEOUT') throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function inspectPage(session )
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
await session.send('Runtime.enable');
|
|
96
|
+
await session.send('Accessibility.enable');
|
|
97
|
+
const [identity, ax, dom] = await Promise.all([
|
|
98
|
+
pageIdentity(session),
|
|
99
|
+
session.send('Accessibility.getFullAXTree', { depth: 8 }),
|
|
100
|
+
session.send('Runtime.evaluate', { expression: `(${semanticSnapshotFunction.toString()})()`, returnByValue: true })
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const nodes = Array.isArray(ax.nodes) ? ax.nodes : [];
|
|
104
|
+
const accessibility = nodes.flatMap((node) => {
|
|
105
|
+
const role = extractCdpValue(node.role);
|
|
106
|
+
const name = extractCdpValue(node.name);
|
|
107
|
+
if (!role || !name || !isUsefulRole(role)) return [];
|
|
108
|
+
const value = extractCdpValue(node.value);
|
|
109
|
+
return [{ role, name: name.slice(0, 240), ...(value ? { value: value.slice(0, 240) } : {}) }];
|
|
110
|
+
}).slice(0, MAX_AX_NODES);
|
|
111
|
+
|
|
112
|
+
return { ...identity, accessibility, semantic: unwrapRuntimeValue(dom) ?? null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractCdpValue(input ) {
|
|
116
|
+
if (!input || typeof input !== 'object') return '';
|
|
117
|
+
const value = (input ).value;
|
|
118
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isUsefulRole(role ) {
|
|
122
|
+
return new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'tab', 'menuitem', 'heading', 'navigation', 'main', 'form', 'dialog', 'alert', 'treeitem', 'option']).has(role.toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function normalizeTargetSpec(input ) {
|
|
126
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
127
|
+
const clean = (key ) => typeof raw[key] === 'string' && String(raw[key]).trim() ? String(raw[key]).trim().slice(0, 500) : undefined;
|
|
128
|
+
return { css: clean('css'), text: clean('text'), role: clean('role'), name: clean('name') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function collectDiagnostics(session )
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
const consoleErrors = [];
|
|
136
|
+
const networkFailures = [];
|
|
137
|
+
await Promise.allSettled([session.send('Runtime.enable'), session.send('Network.enable'), session.send('Log.enable')]);
|
|
138
|
+
const off = [
|
|
139
|
+
session.on('Runtime.exceptionThrown', (params) => {
|
|
140
|
+
const details = params.exceptionDetails ;
|
|
141
|
+
const text = typeof details?.text === 'string' ? details.text : 'Uncaught page exception';
|
|
142
|
+
if (consoleErrors.length < 50) consoleErrors.push(text.slice(0, 500));
|
|
143
|
+
}),
|
|
144
|
+
session.on('Log.entryAdded', (params) => {
|
|
145
|
+
const entry = params.entry ;
|
|
146
|
+
if (entry?.level === 'error' && typeof entry.text === 'string' && consoleErrors.length < 50) consoleErrors.push(entry.text.slice(0, 500));
|
|
147
|
+
}),
|
|
148
|
+
session.on('Network.loadingFailed', (params) => {
|
|
149
|
+
if (networkFailures.length >= 50) return;
|
|
150
|
+
networkFailures.push({
|
|
151
|
+
url: typeof params.url === 'string' ? params.url.slice(0, 1000) : undefined,
|
|
152
|
+
errorText: typeof params.errorText === 'string' ? params.errorText.slice(0, 500) : undefined,
|
|
153
|
+
blockedReason: typeof params.blockedReason === 'string' ? params.blockedReason : undefined
|
|
154
|
+
});
|
|
155
|
+
})
|
|
156
|
+
];
|
|
157
|
+
return {
|
|
158
|
+
stop: () => off.forEach((fn) => fn()),
|
|
159
|
+
snapshot: () => ({ consoleErrors: [...consoleErrors], networkFailures: [...networkFailures] })
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function failure(action , provider , started , error ) {
|
|
164
|
+
const op = error instanceof OperatorError
|
|
165
|
+
? error
|
|
166
|
+
: new OperatorError('CDP_UNAVAILABLE', error instanceof Error ? error.message : String(error), { retryable: true });
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
capability: action.capability,
|
|
170
|
+
provider,
|
|
171
|
+
evidence: [evidence('browser_state', 'fail', op.message, { code: op.code })],
|
|
172
|
+
error: { code: op.code, message: op.message, retryable: op.retryable },
|
|
173
|
+
durationMs: Math.round(performance.now() - started)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function delay(ms , signal ) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
throwIfAborted(signal);
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
signal?.removeEventListener('abort', onAbort);
|
|
182
|
+
resolve();
|
|
183
|
+
}, ms);
|
|
184
|
+
const onAbort = () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
reject(abortError());
|
|
187
|
+
};
|
|
188
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function throwIfAborted(signal ) {
|
|
193
|
+
if (signal?.aborted) throw abortError();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function abortError() {
|
|
197
|
+
return new OperatorError('EXECUTION_ABORTED', 'Browser execution was cancelled.', { retryable: false });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function semanticSnapshotFunction() {
|
|
201
|
+
const trim = (value , max = 180) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
202
|
+
const deepQuery = (selector , max = 1000) => {
|
|
203
|
+
const found = [];
|
|
204
|
+
const seenScopes = new Set ();
|
|
205
|
+
let scanned = 0;
|
|
206
|
+
const visit = (scope , frameDepth , shadowDepth ) => {
|
|
207
|
+
if (!scope || seenScopes.has(scope) || found.length >= max || scanned >= 5000) return;
|
|
208
|
+
seenScopes.add(scope);
|
|
209
|
+
let elements = [];
|
|
210
|
+
try { elements = Array.from(scope.querySelectorAll('*')).slice(0, 2500); } catch { return; }
|
|
211
|
+
for (const element of elements) {
|
|
212
|
+
if (found.length >= max || scanned++ >= 5000) break;
|
|
213
|
+
try { if (element.matches(selector)) found.push({ element, context: { frameDepth, shadowDepth } }); } catch { /* invalid selector */ }
|
|
214
|
+
const shadow = (element ).shadowRoot;
|
|
215
|
+
if (shadow && shadowDepth < 8) visit(shadow, frameDepth, shadowDepth + 1);
|
|
216
|
+
if (element.tagName === 'IFRAME' && frameDepth < 4) {
|
|
217
|
+
try {
|
|
218
|
+
const frameDocument = (element ).contentDocument;
|
|
219
|
+
if (frameDocument?.documentElement) visit(frameDocument, frameDepth + 1, shadowDepth);
|
|
220
|
+
} catch { /* cross-origin frame: remain isolated */ }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
visit(document, 0, 0);
|
|
225
|
+
return found;
|
|
226
|
+
};
|
|
227
|
+
const viewOf = (element ) => element.ownerDocument?.defaultView;
|
|
228
|
+
const visible = (element ) => {
|
|
229
|
+
const view = viewOf(element);
|
|
230
|
+
const style = view?.getComputedStyle?.(element);
|
|
231
|
+
const rect = (element ).getBoundingClientRect?.();
|
|
232
|
+
if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
|
|
233
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
234
|
+
};
|
|
235
|
+
const accessibleName = (element ) => {
|
|
236
|
+
const aria = element.getAttribute('aria-label');
|
|
237
|
+
if (aria) return trim(aria);
|
|
238
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
239
|
+
if (labelledBy) {
|
|
240
|
+
const root = element.getRootNode() ;
|
|
241
|
+
const labels = labelledBy.split(/\s+/).map((id) => (root ).getElementById?.(id)?.textContent ?? '').filter(Boolean);
|
|
242
|
+
if (labels.length) return trim(labels.join(' '));
|
|
243
|
+
}
|
|
244
|
+
const control = element ;
|
|
245
|
+
if (control.labels?.length) return trim(Array.from(control.labels).map((label) => label.textContent).join(' '));
|
|
246
|
+
const nativeText = ['BUTTON', 'SUMMARY', 'A', 'OPTION'].includes(element.tagName) ? trim(element.textContent) : '';
|
|
247
|
+
return trim(
|
|
248
|
+
element.getAttribute('placeholder')
|
|
249
|
+
|| nativeText
|
|
250
|
+
|| element.getAttribute('title')
|
|
251
|
+
|| element.getAttribute('name')
|
|
252
|
+
|| element.id
|
|
253
|
+
|| element.textContent
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
const roleOf = (element ) => {
|
|
257
|
+
const explicit = trim(element.getAttribute('role')).toLowerCase();
|
|
258
|
+
if (explicit) return explicit;
|
|
259
|
+
const tag = element.tagName;
|
|
260
|
+
if (tag === 'A' && element.hasAttribute('href')) return 'link';
|
|
261
|
+
if (tag === 'BUTTON' || tag === 'SUMMARY') return 'button';
|
|
262
|
+
if (tag === 'TEXTAREA') return 'textbox';
|
|
263
|
+
if (tag === 'SELECT') return 'combobox';
|
|
264
|
+
if (tag === 'OPTION') return 'option';
|
|
265
|
+
if (tag === 'INPUT') {
|
|
266
|
+
const type = trim(element.getAttribute('type') || 'text').toLowerCase();
|
|
267
|
+
if (type === 'checkbox') return 'checkbox';
|
|
268
|
+
if (type === 'radio') return 'radio';
|
|
269
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button';
|
|
270
|
+
if (type === 'search') return 'searchbox';
|
|
271
|
+
return 'textbox';
|
|
272
|
+
}
|
|
273
|
+
return '';
|
|
274
|
+
};
|
|
275
|
+
const controls = deepQuery('button,a[href],input,textarea,select,option,summary,[role],[contenteditable="true"]', 160)
|
|
276
|
+
.filter(({ element }) => visible(element))
|
|
277
|
+
.map(({ element, context }) => ({
|
|
278
|
+
tag: element.tagName.toLowerCase(),
|
|
279
|
+
role: roleOf(element),
|
|
280
|
+
name: accessibleName(element),
|
|
281
|
+
type: element.tagName === 'INPUT' ? trim(element.getAttribute('type') || 'text') : '',
|
|
282
|
+
href: element.tagName === 'A' ? trim((element ).href, 500) : '',
|
|
283
|
+
context
|
|
284
|
+
}))
|
|
285
|
+
.filter((item) => item.name || item.href)
|
|
286
|
+
.slice(0, 120);
|
|
287
|
+
const headings = deepQuery('h1,h2,h3,[role="heading"]', 80).filter(({ element }) => visible(element)).map(({ element }) => trim(element.textContent)).filter(Boolean).slice(0, 60);
|
|
288
|
+
const forms = deepQuery('form', 30).map(({ element: form, context }) => {
|
|
289
|
+
const anyForm = form ;
|
|
290
|
+
return {
|
|
291
|
+
name: trim(form.getAttribute('aria-label') || form.getAttribute('name') || form.id),
|
|
292
|
+
action: trim(anyForm.action, 500),
|
|
293
|
+
fields: Array.from(anyForm.elements ?? []).slice(0, 60).map((field) => field instanceof Element ? accessibleName(field) : '').filter(Boolean),
|
|
294
|
+
context
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
return { headings, controls, forms, textExcerpt: trim(document.body?.innerText, 1600) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function interactionFunction(input ) {
|
|
301
|
+
const trim = (value ) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
302
|
+
const deepQuery = (selector , max = 1000) => {
|
|
303
|
+
const found = [];
|
|
304
|
+
const seenScopes = new Set ();
|
|
305
|
+
let scanned = 0;
|
|
306
|
+
const visit = (scope , frameDepth , shadowDepth ) => {
|
|
307
|
+
if (!scope || seenScopes.has(scope) || found.length >= max || scanned >= 5000) return;
|
|
308
|
+
seenScopes.add(scope);
|
|
309
|
+
let elements = [];
|
|
310
|
+
try { elements = Array.from(scope.querySelectorAll('*')).slice(0, 2500); } catch { return; }
|
|
311
|
+
for (const element of elements) {
|
|
312
|
+
if (found.length >= max || scanned++ >= 5000) break;
|
|
313
|
+
try { if (element.matches(selector)) found.push({ element, context: { frameDepth, shadowDepth } }); } catch { /* invalid selector */ }
|
|
314
|
+
const shadow = (element ).shadowRoot;
|
|
315
|
+
if (shadow && shadowDepth < 8) visit(shadow, frameDepth, shadowDepth + 1);
|
|
316
|
+
if (element.tagName === 'IFRAME' && frameDepth < 4) {
|
|
317
|
+
try {
|
|
318
|
+
const frameDocument = (element ).contentDocument;
|
|
319
|
+
if (frameDocument?.documentElement) visit(frameDocument, frameDepth + 1, shadowDepth);
|
|
320
|
+
} catch { /* cross-origin frame: remain isolated */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
visit(document, 0, 0);
|
|
325
|
+
return found;
|
|
326
|
+
};
|
|
327
|
+
const nameOf = (element ) => {
|
|
328
|
+
const aria = element.getAttribute('aria-label');
|
|
329
|
+
if (aria) return trim(aria);
|
|
330
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
331
|
+
if (labelledBy) {
|
|
332
|
+
const root = element.getRootNode() ;
|
|
333
|
+
const labels = labelledBy.split(/\s+/).map((id) => (root ).getElementById?.(id)?.textContent ?? '').filter(Boolean);
|
|
334
|
+
if (labels.length) return trim(labels.join(' '));
|
|
335
|
+
}
|
|
336
|
+
const control = element ;
|
|
337
|
+
if (control.labels?.length) return trim(Array.from(control.labels).map((label) => label.textContent).join(' '));
|
|
338
|
+
const nativeText = ['BUTTON', 'SUMMARY', 'A', 'OPTION'].includes(element.tagName) ? trim(element.textContent) : '';
|
|
339
|
+
return trim(
|
|
340
|
+
element.getAttribute('placeholder')
|
|
341
|
+
|| nativeText
|
|
342
|
+
|| element.getAttribute('title')
|
|
343
|
+
|| element.getAttribute('name')
|
|
344
|
+
|| element.id
|
|
345
|
+
|| element.textContent
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
const roleOf = (element ) => {
|
|
349
|
+
const explicit = trim(element.getAttribute('role')).toLowerCase();
|
|
350
|
+
if (explicit) return explicit;
|
|
351
|
+
const tag = element.tagName;
|
|
352
|
+
if (tag === 'A' && element.hasAttribute('href')) return 'link';
|
|
353
|
+
if (tag === 'BUTTON' || tag === 'SUMMARY') return 'button';
|
|
354
|
+
if (tag === 'TEXTAREA') return 'textbox';
|
|
355
|
+
if (tag === 'SELECT') return 'combobox';
|
|
356
|
+
if (tag === 'OPTION') return 'option';
|
|
357
|
+
if (tag === 'INPUT') {
|
|
358
|
+
const type = trim(element.getAttribute('type') || 'text').toLowerCase();
|
|
359
|
+
if (type === 'checkbox') return 'checkbox';
|
|
360
|
+
if (type === 'radio') return 'radio';
|
|
361
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button';
|
|
362
|
+
if (type === 'search') return 'searchbox';
|
|
363
|
+
return 'textbox';
|
|
364
|
+
}
|
|
365
|
+
return '';
|
|
366
|
+
};
|
|
367
|
+
const visible = (element ) => {
|
|
368
|
+
const view = element.ownerDocument?.defaultView;
|
|
369
|
+
const style = view?.getComputedStyle?.(element);
|
|
370
|
+
const rect = (element ).getBoundingClientRect?.();
|
|
371
|
+
if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
|
|
372
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
373
|
+
};
|
|
374
|
+
const selector = input.target.css || 'button,a[href],input,textarea,select,option,summary,[role],[contenteditable="true"]';
|
|
375
|
+
const candidates = deepQuery(selector, 1000);
|
|
376
|
+
const match = candidates.find(({ element }) => {
|
|
377
|
+
if (!visible(element)) return false;
|
|
378
|
+
if (input.target.text && !trim(element.textContent).toLowerCase().includes(input.target.text.toLowerCase())) return false;
|
|
379
|
+
if (input.target.role && roleOf(element) !== input.target.role.toLowerCase()) return false;
|
|
380
|
+
if (input.target.name && nameOf(element).toLowerCase() !== input.target.name.toLowerCase()) return false;
|
|
381
|
+
return true;
|
|
382
|
+
});
|
|
383
|
+
if (!match) return { ok: false, error: 'No matching semantic element was found.' };
|
|
384
|
+
const { element, context } = match;
|
|
385
|
+
const control = element
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
;
|
|
393
|
+
if (control.disabled === true || element.getAttribute('aria-disabled') === 'true') return { ok: false, error: 'Matched element is disabled.' };
|
|
394
|
+
const before = { name: nameOf(element), role: roleOf(element), value: typeof control.value === 'string' ? control.value : '' };
|
|
395
|
+
const view = element.ownerDocument?.defaultView ?? window;
|
|
396
|
+
|
|
397
|
+
if (input.operation === 'click') {
|
|
398
|
+
control.focus?.();
|
|
399
|
+
if (typeof control.click !== 'function') return { ok: false, error: 'Matched element is not clickable.' };
|
|
400
|
+
control.click();
|
|
401
|
+
} else if (input.operation === 'type') {
|
|
402
|
+
const value = String(input.value ?? '');
|
|
403
|
+
const tag = element.tagName;
|
|
404
|
+
if (!(tag === 'INPUT' || tag === 'TEXTAREA' || control.isContentEditable === true)) return { ok: false, error: 'Matched element is not text-editable.' };
|
|
405
|
+
control.focus?.();
|
|
406
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
407
|
+
const ctor = tag === 'INPUT' ? view.HTMLInputElement : view.HTMLTextAreaElement;
|
|
408
|
+
const descriptor = ctor ? Object.getOwnPropertyDescriptor(ctor.prototype, 'value') : undefined;
|
|
409
|
+
if (descriptor?.set) descriptor.set.call(element, value);
|
|
410
|
+
else control.value = value;
|
|
411
|
+
} else {
|
|
412
|
+
element.textContent = value;
|
|
413
|
+
}
|
|
414
|
+
const InputEventCtor = view.InputEvent ?? view.Event;
|
|
415
|
+
control.dispatchEvent?.(new InputEventCtor('input', { bubbles: true, ...(view.InputEvent ? { inputType: 'insertText', data: value } : {}) } ));
|
|
416
|
+
control.dispatchEvent?.(new view.Event('change', { bubbles: true }));
|
|
417
|
+
const actual = tag === 'INPUT' || tag === 'TEXTAREA' ? String(control.value ?? '') : trim(element.textContent);
|
|
418
|
+
if (actual !== value) return { ok: false, error: 'Text input postcondition failed.', expected: value, actual };
|
|
419
|
+
} else if (input.operation === 'select') {
|
|
420
|
+
if (element.tagName !== 'SELECT') return { ok: false, error: 'Matched element is not a select control.' };
|
|
421
|
+
const value = String(input.value ?? '');
|
|
422
|
+
control.value = value;
|
|
423
|
+
control.dispatchEvent?.(new view.Event('input', { bubbles: true }));
|
|
424
|
+
control.dispatchEvent?.(new view.Event('change', { bubbles: true }));
|
|
425
|
+
if (String(control.value ?? '') !== value) return { ok: false, error: 'Select postcondition failed.', expected: value, actual: String(control.value ?? '') };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const after = { name: nameOf(element), role: roleOf(element), value: typeof control.value === 'string' ? control.value : '' };
|
|
429
|
+
return { ok: true, matched: { tag: element.tagName.toLowerCase(), ...before, context }, after };
|
|
430
|
+
}
|