@farmslot/recipe-runner 0.23.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/CHANGELOG.md +255 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/farmslot-visual-review.mjs +152 -0
- package/dist/adapters/app-lifecycle.d.ts +41 -0
- package/dist/adapters/app-lifecycle.d.ts.map +1 -0
- package/dist/adapters/app-lifecycle.js +224 -0
- package/dist/adapters/app-lifecycle.js.map +1 -0
- package/dist/adapters/core.d.ts +5 -0
- package/dist/adapters/core.d.ts.map +1 -0
- package/dist/adapters/core.js +433 -0
- package/dist/adapters/core.js.map +1 -0
- package/dist/adapters/gesture.d.ts +12 -0
- package/dist/adapters/gesture.d.ts.map +1 -0
- package/dist/adapters/gesture.js +76 -0
- package/dist/adapters/gesture.js.map +1 -0
- package/dist/adapters/ui.d.ts +33 -0
- package/dist/adapters/ui.d.ts.map +1 -0
- package/dist/adapters/ui.js +130 -0
- package/dist/adapters/ui.js.map +1 -0
- package/dist/cli/error-output.d.ts +6 -0
- package/dist/cli/error-output.d.ts.map +1 -0
- package/dist/cli/error-output.js +19 -0
- package/dist/cli/error-output.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run-command.d.ts +8 -0
- package/dist/cli/run-command.d.ts.map +1 -0
- package/dist/cli/run-command.js +253 -0
- package/dist/cli/run-command.js.map +1 -0
- package/dist/cli/support.d.ts +31 -0
- package/dist/cli/support.d.ts.map +1 -0
- package/dist/cli/support.js +297 -0
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/validate-command.d.ts +4 -0
- package/dist/cli/validate-command.d.ts.map +1 -0
- package/dist/cli/validate-command.js +52 -0
- package/dist/cli/validate-command.js.map +1 -0
- package/dist/core/compose.d.ts +23 -0
- package/dist/core/compose.d.ts.map +1 -0
- package/dist/core/compose.js +104 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/execution.d.ts +44 -0
- package/dist/core/execution.d.ts.map +1 -0
- package/dist/core/execution.js +304 -0
- package/dist/core/execution.js.map +1 -0
- package/dist/core/failure.d.ts +23 -0
- package/dist/core/failure.d.ts.map +1 -0
- package/dist/core/failure.js +43 -0
- package/dist/core/failure.js.map +1 -0
- package/dist/core/graph.d.ts +12 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +65 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/hud.d.ts +11 -0
- package/dist/core/hud.d.ts.map +1 -0
- package/dist/core/hud.js +120 -0
- package/dist/core/hud.js.map +1 -0
- package/dist/core/invocation.d.ts +13 -0
- package/dist/core/invocation.d.ts.map +1 -0
- package/dist/core/invocation.js +68 -0
- package/dist/core/invocation.js.map +1 -0
- package/dist/core/json.d.ts +9 -0
- package/dist/core/json.d.ts.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/library-manifest.d.ts +53 -0
- package/dist/core/library-manifest.d.ts.map +1 -0
- package/dist/core/library-manifest.js +263 -0
- package/dist/core/library-manifest.js.map +1 -0
- package/dist/core/library.d.ts +80 -0
- package/dist/core/library.d.ts.map +1 -0
- package/dist/core/library.js +321 -0
- package/dist/core/library.js.map +1 -0
- package/dist/core/parameters.d.ts +5 -0
- package/dist/core/parameters.d.ts.map +1 -0
- package/dist/core/parameters.js +57 -0
- package/dist/core/parameters.js.map +1 -0
- package/dist/core/passive-observations.d.ts +25 -0
- package/dist/core/passive-observations.d.ts.map +1 -0
- package/dist/core/passive-observations.js +62 -0
- package/dist/core/passive-observations.js.map +1 -0
- package/dist/core/path.d.ts +7 -0
- package/dist/core/path.d.ts.map +1 -0
- package/dist/core/path.js +147 -0
- package/dist/core/path.js.map +1 -0
- package/dist/core/recording-cleanup.d.ts +10 -0
- package/dist/core/recording-cleanup.d.ts.map +1 -0
- package/dist/core/recording-cleanup.js +32 -0
- package/dist/core/recording-cleanup.js.map +1 -0
- package/dist/core/resolution-error.d.ts +7 -0
- package/dist/core/resolution-error.d.ts.map +1 -0
- package/dist/core/resolution-error.js +9 -0
- package/dist/core/resolution-error.js.map +1 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.d.ts.map +1 -0
- package/dist/core/runner.js +738 -0
- package/dist/core/runner.js.map +1 -0
- package/dist/core/scroll-to.d.ts +111 -0
- package/dist/core/scroll-to.d.ts.map +1 -0
- package/dist/core/scroll-to.js +353 -0
- package/dist/core/scroll-to.js.map +1 -0
- package/dist/core/suite.d.ts +25 -0
- package/dist/core/suite.d.ts.map +1 -0
- package/dist/core/suite.js +76 -0
- package/dist/core/suite.js.map +1 -0
- package/dist/core/trace.d.ts +5 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +25 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/trust-error.d.ts +9 -0
- package/dist/core/trust-error.d.ts.map +1 -0
- package/dist/core/trust-error.js +18 -0
- package/dist/core/trust-error.js.map +1 -0
- package/dist/core/trust-input.d.ts +20 -0
- package/dist/core/trust-input.d.ts.map +1 -0
- package/dist/core/trust-input.js +68 -0
- package/dist/core/trust-input.js.map +1 -0
- package/dist/core/trust.d.ts +28 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +284 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/types.d.ts +319 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validation.d.ts +4 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +18 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/node/writers.d.ts +27 -0
- package/dist/node/writers.d.ts.map +1 -0
- package/dist/node/writers.js +99 -0
- package/dist/node/writers.js.map +1 -0
- package/dist/recording/android-mirror.d.ts +10 -0
- package/dist/recording/android-mirror.d.ts.map +1 -0
- package/dist/recording/android-mirror.js +180 -0
- package/dist/recording/android-mirror.js.map +1 -0
- package/dist/recording/capture-helper-timing.d.ts +4 -0
- package/dist/recording/capture-helper-timing.d.ts.map +1 -0
- package/dist/recording/capture-helper-timing.js +47 -0
- package/dist/recording/capture-helper-timing.js.map +1 -0
- package/dist/recording/capture-helper.d.ts +10 -0
- package/dist/recording/capture-helper.d.ts.map +1 -0
- package/dist/recording/capture-helper.js +335 -0
- package/dist/recording/capture-helper.js.map +1 -0
- package/dist/recording/cdp-video-recorder.d.ts +9 -0
- package/dist/recording/cdp-video-recorder.d.ts.map +1 -0
- package/dist/recording/cdp-video-recorder.js +319 -0
- package/dist/recording/cdp-video-recorder.js.map +1 -0
- package/dist/recording/frame-sampler.d.ts +19 -0
- package/dist/recording/frame-sampler.d.ts.map +1 -0
- package/dist/recording/frame-sampler.js +29 -0
- package/dist/recording/frame-sampler.js.map +1 -0
- package/dist/recording/timeline.d.ts +15 -0
- package/dist/recording/timeline.d.ts.map +1 -0
- package/dist/recording/timeline.js +88 -0
- package/dist/recording/timeline.js.map +1 -0
- package/dist/runtime/browser-extension.d.ts +17 -0
- package/dist/runtime/browser-extension.d.ts.map +1 -0
- package/dist/runtime/browser-extension.js +42 -0
- package/dist/runtime/browser-extension.js.map +1 -0
- package/dist/runtime/cdp.d.ts +138 -0
- package/dist/runtime/cdp.d.ts.map +1 -0
- package/dist/runtime/cdp.js +1429 -0
- package/dist/runtime/cdp.js.map +1 -0
- package/dist/runtime/decision-types.d.ts +23 -0
- package/dist/runtime/decision-types.d.ts.map +1 -0
- package/dist/runtime/decision-types.js +2 -0
- package/dist/runtime/decision-types.js.map +1 -0
- package/dist/runtime/deps-readiness.d.ts +21 -0
- package/dist/runtime/deps-readiness.d.ts.map +1 -0
- package/dist/runtime/deps-readiness.js +141 -0
- package/dist/runtime/deps-readiness.js.map +1 -0
- package/dist/runtime/log-analysis.d.ts +48 -0
- package/dist/runtime/log-analysis.d.ts.map +1 -0
- package/dist/runtime/log-analysis.js +128 -0
- package/dist/runtime/log-analysis.js.map +1 -0
- package/dist/runtime/metro-probe.d.ts +5 -0
- package/dist/runtime/metro-probe.d.ts.map +1 -0
- package/dist/runtime/metro-probe.js +24 -0
- package/dist/runtime/metro-probe.js.map +1 -0
- package/dist/runtime/operation.d.ts +32 -0
- package/dist/runtime/operation.d.ts.map +1 -0
- package/dist/runtime/operation.js +184 -0
- package/dist/runtime/operation.js.map +1 -0
- package/dist/runtime/orchestrate-up.d.ts +22 -0
- package/dist/runtime/orchestrate-up.d.ts.map +1 -0
- package/dist/runtime/orchestrate-up.js +35 -0
- package/dist/runtime/orchestrate-up.js.map +1 -0
- package/dist/runtime/react-native-bridge.d.ts +17 -0
- package/dist/runtime/react-native-bridge.d.ts.map +1 -0
- package/dist/runtime/react-native-bridge.js +57 -0
- package/dist/runtime/react-native-bridge.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +163 -0
- package/test/fixtures/scroll-to-visible/README.md +39 -0
- package/visual-review/feedback-draft.mjs +82 -0
- package/visual-review/index.d.mts +41 -0
- package/visual-review/index.mjs +4 -0
- package/visual-review/recipe-board.mjs +142 -0
- package/visual-review/review-board.mjs +981 -0
- package/visual-review/server.mjs +69 -0
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { gestureDurationMs, gesturePhase, gesturePoints, gestureSegmentDuration, gestureTarget, } from '../adapters/gesture.js';
|
|
4
|
+
import { asNumber, asOptionalString, asString, isRecord } from '../core/json.js';
|
|
5
|
+
import { writeFileWithinRoot } from '../core/path.js';
|
|
6
|
+
import { UiScrollToError, } from '../core/scroll-to.js';
|
|
7
|
+
export function sleep(ms) {
|
|
8
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
export function dataTestId(testId) {
|
|
11
|
+
return `[data-testid="${escapeCssAttrValue(testId)}"]`;
|
|
12
|
+
}
|
|
13
|
+
class CdpCallTimeoutError extends Error {
|
|
14
|
+
}
|
|
15
|
+
export async function jsonGet(url, options = {}) {
|
|
16
|
+
const controller = options.timeoutMs === undefined ? undefined : new AbortController();
|
|
17
|
+
const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(url, { signal: controller?.signal });
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new Error(`GET ${url} failed with HTTP ${response.status}.`);
|
|
22
|
+
return (await response.json());
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (controller?.signal.aborted) {
|
|
26
|
+
throw new Error(`GET ${url} timed out after ${options.timeoutMs}ms.`);
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
if (timer)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function retryJsonGet(url, options = {}) {
|
|
36
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
37
|
+
const intervalMs = options.intervalMs ?? 100;
|
|
38
|
+
const deadline = Date.now() + timeoutMs;
|
|
39
|
+
let lastError;
|
|
40
|
+
while (Date.now() <= deadline) {
|
|
41
|
+
try {
|
|
42
|
+
return await jsonGet(url, {
|
|
43
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Expected while a browser/debug target is still starting; retry until the caller's deadline.
|
|
48
|
+
lastError = error;
|
|
49
|
+
const remainingMs = deadline - Date.now();
|
|
50
|
+
if (remainingMs <= 0)
|
|
51
|
+
break;
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remainingMs)));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
56
|
+
throw new Error(`GET ${url} did not succeed within ${timeoutMs}ms: ${message}`);
|
|
57
|
+
}
|
|
58
|
+
export async function listCdpTargets(host, port) {
|
|
59
|
+
return retryJsonGet(`http://${host}:${port}/json`);
|
|
60
|
+
}
|
|
61
|
+
export async function selectCdpTarget(options) {
|
|
62
|
+
const host = options.host ?? '127.0.0.1';
|
|
63
|
+
const targets = await listCdpTargets(host, options.port);
|
|
64
|
+
const selected = targets.find((target) => {
|
|
65
|
+
if (options.type && target.type !== options.type)
|
|
66
|
+
return false;
|
|
67
|
+
if (options.urlIncludes && !target.url?.includes(options.urlIncludes))
|
|
68
|
+
return false;
|
|
69
|
+
if (options.titleIncludes && !target.title?.includes(options.titleIncludes))
|
|
70
|
+
return false;
|
|
71
|
+
if (options.predicate && !options.predicate(target))
|
|
72
|
+
return false;
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
if (!selected) {
|
|
76
|
+
throw new Error(`No CDP target matched ${JSON.stringify({
|
|
77
|
+
host,
|
|
78
|
+
port: options.port,
|
|
79
|
+
type: options.type,
|
|
80
|
+
urlIncludes: options.urlIncludes,
|
|
81
|
+
titleIncludes: options.titleIncludes,
|
|
82
|
+
})}.`);
|
|
83
|
+
}
|
|
84
|
+
return selected;
|
|
85
|
+
}
|
|
86
|
+
export class CdpSession {
|
|
87
|
+
#ws;
|
|
88
|
+
#pending = new Map();
|
|
89
|
+
#eventHandlers = new Map();
|
|
90
|
+
#nextId = 0;
|
|
91
|
+
constructor(ws) {
|
|
92
|
+
this.#ws = ws;
|
|
93
|
+
ws.on('message', (buffer) => this.#handleMessage(buffer.toString()));
|
|
94
|
+
ws.on('error', (error) => this.#rejectAll(error));
|
|
95
|
+
ws.on('close', () => this.#rejectAll(new Error('CDP websocket closed.')));
|
|
96
|
+
}
|
|
97
|
+
static async connect(webSocketDebuggerUrl, options = {}) {
|
|
98
|
+
const ws = new WebSocket(webSocketDebuggerUrl);
|
|
99
|
+
const timeoutMs = options.timeoutMs;
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
let timer;
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
ws.off('open', onOpen);
|
|
104
|
+
ws.off('error', onError);
|
|
105
|
+
if (timer)
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
};
|
|
108
|
+
const onOpen = () => {
|
|
109
|
+
cleanup();
|
|
110
|
+
resolve();
|
|
111
|
+
};
|
|
112
|
+
const onError = (error) => {
|
|
113
|
+
cleanup();
|
|
114
|
+
reject(error);
|
|
115
|
+
};
|
|
116
|
+
ws.once('open', onOpen);
|
|
117
|
+
ws.once('error', onError);
|
|
118
|
+
if (timeoutMs !== undefined) {
|
|
119
|
+
timer = setTimeout(() => {
|
|
120
|
+
cleanup();
|
|
121
|
+
ws.once('error', () => undefined);
|
|
122
|
+
ws.terminate();
|
|
123
|
+
reject(new Error(`CDP connection timed out after ${timeoutMs}ms.`));
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return new CdpSession(ws);
|
|
128
|
+
}
|
|
129
|
+
async call(method, params = {}, options = {}) {
|
|
130
|
+
const id = ++this.#nextId;
|
|
131
|
+
let timeout;
|
|
132
|
+
const response = new Promise((resolve, reject) => {
|
|
133
|
+
this.#pending.set(id, {
|
|
134
|
+
resolve(value) {
|
|
135
|
+
if (timeout)
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
resolve(value);
|
|
138
|
+
},
|
|
139
|
+
reject(error) {
|
|
140
|
+
if (timeout)
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
reject(error);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
if (options.timeoutMs !== undefined) {
|
|
147
|
+
timeout = setTimeout(() => {
|
|
148
|
+
const pending = this.#pending.get(id);
|
|
149
|
+
if (!pending)
|
|
150
|
+
return;
|
|
151
|
+
this.#pending.delete(id);
|
|
152
|
+
pending.reject(new CdpCallTimeoutError(`CDP ${method} timed out after ${options.timeoutMs}ms.`));
|
|
153
|
+
}, options.timeoutMs);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
this.#ws.send(JSON.stringify({ id, method, params }));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
this.#pending.delete(id);
|
|
160
|
+
if (timeout)
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return response;
|
|
165
|
+
}
|
|
166
|
+
on(method, handler) {
|
|
167
|
+
const handlers = this.#eventHandlers.get(method) ?? new Set();
|
|
168
|
+
handlers.add(handler);
|
|
169
|
+
this.#eventHandlers.set(method, handlers);
|
|
170
|
+
return () => {
|
|
171
|
+
handlers.delete(handler);
|
|
172
|
+
if (handlers.size === 0)
|
|
173
|
+
this.#eventHandlers.delete(method);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
close() {
|
|
177
|
+
this.#ws.close();
|
|
178
|
+
}
|
|
179
|
+
#handleMessage(text) {
|
|
180
|
+
const message = JSON.parse(text);
|
|
181
|
+
if (message.method && message.id == null) {
|
|
182
|
+
const handlers = this.#eventHandlers.get(message.method);
|
|
183
|
+
if (handlers) {
|
|
184
|
+
for (const handler of handlers)
|
|
185
|
+
handler(message.params ?? {});
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (message.id == null)
|
|
190
|
+
return;
|
|
191
|
+
const pending = this.#pending.get(message.id);
|
|
192
|
+
if (!pending)
|
|
193
|
+
return;
|
|
194
|
+
this.#pending.delete(message.id);
|
|
195
|
+
if (message.error) {
|
|
196
|
+
pending.reject(new Error(message.error.message ?? 'CDP call failed.'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
pending.resolve(message.result);
|
|
200
|
+
}
|
|
201
|
+
#rejectAll(error) {
|
|
202
|
+
for (const pending of this.#pending.values())
|
|
203
|
+
pending.reject(error);
|
|
204
|
+
this.#pending.clear();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const COMPOSITOR_WORLD = 'farmslot-compositor-probe';
|
|
208
|
+
const DOM_SETTLEMENT_WORLD = 'farmslot-dom-settlement';
|
|
209
|
+
class CdpPageEvaluationError extends Error {
|
|
210
|
+
}
|
|
211
|
+
const isolatedWorldContexts = new WeakMap();
|
|
212
|
+
async function evaluateCdpSessionInIsolatedWorld(session, expression, worldName, timeoutMs) {
|
|
213
|
+
let contexts = isolatedWorldContexts.get(session);
|
|
214
|
+
if (!contexts) {
|
|
215
|
+
contexts = new Map();
|
|
216
|
+
isolatedWorldContexts.set(session, contexts);
|
|
217
|
+
}
|
|
218
|
+
let isolatedWorld = contexts.get(worldName);
|
|
219
|
+
if (!isolatedWorld) {
|
|
220
|
+
const frameTree = await session.call('Page.getFrameTree', {}, { timeoutMs });
|
|
221
|
+
const frameId = frameTree.frameTree?.frame?.id;
|
|
222
|
+
if (!frameId)
|
|
223
|
+
throw new Error('CDP evaluation could not resolve the page frame.');
|
|
224
|
+
const created = await session.call('Page.createIsolatedWorld', { frameId, worldName }, { timeoutMs });
|
|
225
|
+
if (created.executionContextId === undefined) {
|
|
226
|
+
throw new Error('CDP evaluation could not create an isolated world.');
|
|
227
|
+
}
|
|
228
|
+
isolatedWorld = { executionContextId: created.executionContextId };
|
|
229
|
+
contexts.set(worldName, isolatedWorld);
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const result = await session.call('Runtime.evaluate', {
|
|
233
|
+
expression,
|
|
234
|
+
contextId: isolatedWorld.executionContextId,
|
|
235
|
+
awaitPromise: true,
|
|
236
|
+
returnByValue: true,
|
|
237
|
+
}, { timeoutMs });
|
|
238
|
+
if (result.exceptionDetails) {
|
|
239
|
+
throw new CdpPageEvaluationError(result.exceptionDetails.exception?.description ??
|
|
240
|
+
result.exceptionDetails.text ??
|
|
241
|
+
'CDP isolated-world evaluation failed.');
|
|
242
|
+
}
|
|
243
|
+
return result.result?.value;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// Navigation destroys the cached execution context. Evicting it is safe
|
|
247
|
+
// because the retry resolves the current main frame and creates a new world.
|
|
248
|
+
if (isTransientCdpContextError(error))
|
|
249
|
+
contexts.delete(worldName);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function retryTransientCdpContext(deadline, attempt, minimumRetryBudgetMs = 0) {
|
|
254
|
+
let lastError;
|
|
255
|
+
do {
|
|
256
|
+
try {
|
|
257
|
+
return await attempt(Math.max(1, deadline - Date.now()));
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
// A document commit temporarily invalidates its frame or execution
|
|
261
|
+
// context. Retrying is correct while the caller's total budget remains.
|
|
262
|
+
if (!isTransientCdpContextError(error))
|
|
263
|
+
throw error;
|
|
264
|
+
lastError = error;
|
|
265
|
+
const remainingMs = deadline - Date.now();
|
|
266
|
+
if (remainingMs <= minimumRetryBudgetMs)
|
|
267
|
+
break;
|
|
268
|
+
await sleep(Math.min(25, remainingMs));
|
|
269
|
+
}
|
|
270
|
+
} while (Date.now() < deadline);
|
|
271
|
+
throw lastError;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Distinguish a reachable JavaScript context from a renderable, interactive
|
|
275
|
+
* browser page. Hardened pages are retried in an isolated world so page policy
|
|
276
|
+
* cannot hide requestAnimationFrame from the readiness probe. Navigation races
|
|
277
|
+
* return a suspended report rather than rejecting the probe.
|
|
278
|
+
*/
|
|
279
|
+
export async function probeCdpCompositorInteractivity(session, timeoutMs = 1_000) {
|
|
280
|
+
const frameTimeoutReason = `Compositor frame did not advance within ${timeoutMs}ms`;
|
|
281
|
+
// Reserve a small outer-timer margin so the retry loop can surface its final
|
|
282
|
+
// transient error instead of racing a timeout scheduled for the same instant.
|
|
283
|
+
const retryGraceMs = Math.min(50, Math.max(1, Math.floor(timeoutMs / 5)));
|
|
284
|
+
const deadline = Date.now() + Math.max(1, timeoutMs - retryGraceMs);
|
|
285
|
+
const expression = `(async () => {
|
|
286
|
+
const startedAt = performance.now();
|
|
287
|
+
const frameAdvanced = await new Promise((resolve) => {
|
|
288
|
+
requestAnimationFrame((firstFrame) => {
|
|
289
|
+
requestAnimationFrame((secondFrame) => resolve(secondFrame > firstFrame));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
const candidates = [...document.querySelectorAll('button, a, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"]')];
|
|
293
|
+
const visibleTargets = candidates.filter((element) => {
|
|
294
|
+
if (element.disabled || element.getAttribute('aria-disabled') === 'true') return false;
|
|
295
|
+
const style = getComputedStyle(element);
|
|
296
|
+
const rect = element.getBoundingClientRect();
|
|
297
|
+
return style.display !== 'none' &&
|
|
298
|
+
style.visibility !== 'hidden' &&
|
|
299
|
+
Number(style.opacity || 1) > 0 &&
|
|
300
|
+
rect.width > 0 &&
|
|
301
|
+
rect.height > 0 &&
|
|
302
|
+
rect.left >= 0 &&
|
|
303
|
+
rect.top >= 0 &&
|
|
304
|
+
rect.right <= innerWidth &&
|
|
305
|
+
rect.bottom <= innerHeight;
|
|
306
|
+
});
|
|
307
|
+
let hitTestOk = null;
|
|
308
|
+
if (visibleTargets.length > 0) {
|
|
309
|
+
hitTestOk = visibleTargets.some((target) => {
|
|
310
|
+
const rect = target.getBoundingClientRect();
|
|
311
|
+
const hit = document.elementFromPoint(
|
|
312
|
+
rect.left + rect.width / 2,
|
|
313
|
+
rect.top + rect.height / 2,
|
|
314
|
+
);
|
|
315
|
+
return Boolean(hit && (hit === target || target.contains(hit)));
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
frameAdvanced: frameAdvanced && performance.now() > startedAt,
|
|
320
|
+
interactiveTargetFound: visibleTargets.length > 0,
|
|
321
|
+
hitTestOk,
|
|
322
|
+
};
|
|
323
|
+
})()`;
|
|
324
|
+
const evaluate = (remainingMs) => session.call('Runtime.evaluate', {
|
|
325
|
+
expression,
|
|
326
|
+
awaitPromise: true,
|
|
327
|
+
returnByValue: true,
|
|
328
|
+
}, { timeoutMs: remainingMs });
|
|
329
|
+
try {
|
|
330
|
+
const result = await withTimeout((async () => {
|
|
331
|
+
const pageResult = await retryTransientCdpContext(deadline, evaluate);
|
|
332
|
+
const description = pageResult.exceptionDetails?.exception?.description ??
|
|
333
|
+
pageResult.exceptionDetails?.text ??
|
|
334
|
+
'';
|
|
335
|
+
if (!description.includes('requestAnimationFrame') ||
|
|
336
|
+
!description.includes('inaccessible under scuttling mode')) {
|
|
337
|
+
return pageResult;
|
|
338
|
+
}
|
|
339
|
+
return retryTransientCdpContext(deadline, async (remainingMs) => {
|
|
340
|
+
const value = await evaluateCdpSessionInIsolatedWorld(session, expression, COMPOSITOR_WORLD, remainingMs);
|
|
341
|
+
return { result: { value } };
|
|
342
|
+
});
|
|
343
|
+
})(), timeoutMs, frameTimeoutReason);
|
|
344
|
+
if (result.exceptionDetails) {
|
|
345
|
+
throw new Error(result.exceptionDetails.exception?.description ??
|
|
346
|
+
result.exceptionDetails.text ??
|
|
347
|
+
'CDP compositor probe evaluation failed.');
|
|
348
|
+
}
|
|
349
|
+
const value = result.result?.value;
|
|
350
|
+
if (!value?.frameAdvanced) {
|
|
351
|
+
return {
|
|
352
|
+
status: 'suspended',
|
|
353
|
+
frameAdvanced: false,
|
|
354
|
+
interactiveTargetFound: Boolean(value?.interactiveTargetFound),
|
|
355
|
+
hitTestOk: value?.hitTestOk ?? null,
|
|
356
|
+
reason: 'The compositor frame did not advance.',
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (value.interactiveTargetFound && value.hitTestOk !== true) {
|
|
360
|
+
return {
|
|
361
|
+
status: 'not-interactive',
|
|
362
|
+
frameAdvanced: true,
|
|
363
|
+
interactiveTargetFound: true,
|
|
364
|
+
hitTestOk: false,
|
|
365
|
+
reason: 'The visible interactive target failed browser hit testing.',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
status: 'ready',
|
|
370
|
+
frameAdvanced: true,
|
|
371
|
+
interactiveTargetFound: Boolean(value.interactiveTargetFound),
|
|
372
|
+
hitTestOk: value.hitTestOk ?? null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if ((error instanceof Error && error.message === frameTimeoutReason) ||
|
|
377
|
+
error instanceof CdpCallTimeoutError ||
|
|
378
|
+
isTransientCdpContextError(error)) {
|
|
379
|
+
return {
|
|
380
|
+
status: 'suspended',
|
|
381
|
+
frameAdvanced: false,
|
|
382
|
+
interactiveTargetFound: false,
|
|
383
|
+
hitTestOk: null,
|
|
384
|
+
reason: error instanceof Error && error.message === frameTimeoutReason
|
|
385
|
+
? frameTimeoutReason
|
|
386
|
+
: `Compositor probe could not evaluate: ${String(error instanceof Error ? error.message : error)}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export class CdpWebPage {
|
|
393
|
+
constructor(session) {
|
|
394
|
+
this.session = session;
|
|
395
|
+
}
|
|
396
|
+
static async connectToTarget(target) {
|
|
397
|
+
if (!target.webSocketDebuggerUrl)
|
|
398
|
+
throw new Error(`CDP target ${target.id} has no websocket URL.`);
|
|
399
|
+
const session = await CdpSession.connect(target.webSocketDebuggerUrl);
|
|
400
|
+
await session.call('Runtime.enable');
|
|
401
|
+
await session.call('Page.enable');
|
|
402
|
+
return new CdpWebPage(session);
|
|
403
|
+
}
|
|
404
|
+
async navigate(url, timeoutMs = 10_000) {
|
|
405
|
+
const deadline = Date.now() + timeoutMs;
|
|
406
|
+
const currentUrl = await this.currentNavigationUrl();
|
|
407
|
+
const expectedUrl = new URL(url, currentUrl).href;
|
|
408
|
+
let notifyLoaded;
|
|
409
|
+
const loaded = new Promise((resolve) => {
|
|
410
|
+
notifyLoaded = resolve;
|
|
411
|
+
});
|
|
412
|
+
const unsubscribe = this.session.on('Page.loadEventFired', () => notifyLoaded?.());
|
|
413
|
+
try {
|
|
414
|
+
const result = await this.session.call('Page.navigate', { url: expectedUrl });
|
|
415
|
+
const wasIntercepted = result.errorText === 'net::ERR_ABORTED';
|
|
416
|
+
if (result.errorText && !wasIntercepted) {
|
|
417
|
+
throw new Error(`CDP navigation failed: ${result.errorText}`);
|
|
418
|
+
}
|
|
419
|
+
// Extension external-link interception aborts the requested load before routing the tab.
|
|
420
|
+
if (wasIntercepted) {
|
|
421
|
+
await this.waitForNavigationUrlChange(currentUrl, deadline);
|
|
422
|
+
}
|
|
423
|
+
if (result.loaderId && !wasIntercepted) {
|
|
424
|
+
await withTimeout(loaded, Math.max(1, deadline - Date.now()), `CDP navigation timed out after ${timeoutMs}ms`);
|
|
425
|
+
}
|
|
426
|
+
await this.waitForDocumentReady({
|
|
427
|
+
expectedUrl: result.loaderId || wasIntercepted ? undefined : expectedUrl,
|
|
428
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
429
|
+
});
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
unsubscribe();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async currentNavigationUrl() {
|
|
437
|
+
const history = await this.session.call('Page.getNavigationHistory');
|
|
438
|
+
const currentUrl = history.entries[history.currentIndex]?.url;
|
|
439
|
+
if (!currentUrl)
|
|
440
|
+
throw new Error('CDP could not resolve the current page URL.');
|
|
441
|
+
return currentUrl;
|
|
442
|
+
}
|
|
443
|
+
async waitForNavigationUrlChange(originalUrl, deadline) {
|
|
444
|
+
while (Date.now() <= deadline) {
|
|
445
|
+
if ((await this.currentNavigationUrl()) !== originalUrl)
|
|
446
|
+
return;
|
|
447
|
+
const remainingMs = deadline - Date.now();
|
|
448
|
+
if (remainingMs <= 0)
|
|
449
|
+
break;
|
|
450
|
+
await sleep(Math.min(50, remainingMs));
|
|
451
|
+
}
|
|
452
|
+
if ((await this.currentNavigationUrl()) !== originalUrl)
|
|
453
|
+
return;
|
|
454
|
+
throw new Error('CDP navigation failed: net::ERR_ABORTED');
|
|
455
|
+
}
|
|
456
|
+
async waitForDocumentReady(options) {
|
|
457
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
458
|
+
const deadline = Date.now() + timeoutMs;
|
|
459
|
+
let lastError;
|
|
460
|
+
while (Date.now() <= deadline) {
|
|
461
|
+
try {
|
|
462
|
+
const state = await this.evaluate(`(() => ({ ready: document.readyState === 'interactive' || document.readyState === 'complete', url: location.href }))()`);
|
|
463
|
+
if (state.ready && (!options.expectedUrl || state.url === options.expectedUrl))
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
// Runtime contexts can disappear briefly while a document navigation commits.
|
|
468
|
+
lastError = error;
|
|
469
|
+
}
|
|
470
|
+
await sleep(50);
|
|
471
|
+
}
|
|
472
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : '';
|
|
473
|
+
throw new Error(`CDP document was not ready within ${timeoutMs}ms${detail}`);
|
|
474
|
+
}
|
|
475
|
+
async waitForDomSettled(timeoutMs = 10_000) {
|
|
476
|
+
const quietMs = Math.min(300, Math.max(1, Math.floor(timeoutMs / 2)));
|
|
477
|
+
const deadline = Date.now() + timeoutMs;
|
|
478
|
+
const timeoutReason = `CDP document did not settle within ${timeoutMs}ms`;
|
|
479
|
+
const supersededReason = 'CDP DOM settlement was superseded by another probe.';
|
|
480
|
+
try {
|
|
481
|
+
await retryTransientCdpContext(deadline, async (attemptTimeoutMs) => {
|
|
482
|
+
const expression = `new Promise((resolve, reject) => { const key = '__farmslotDomSettlementCancel'; globalThis[key]?.(); const quietMs = ${quietMs}; const observedRoots = new Set(); let quietTimer; let rootPoll; let timeout; let observer; let finished = false; const cleanup = () => { observer?.disconnect(); clearTimeout(quietTimer); clearTimeout(timeout); clearInterval(rootPoll); if (globalThis[key] === cancel) delete globalThis[key]; }; const cancel = () => { if (finished) return; finished = true; cleanup(); resolve(false); }; globalThis[key] = cancel; const finish = () => { if (finished) return; const animations = typeof document.getAnimations === 'function' ? document.getAnimations({ subtree: true }) : []; const finiteAnimations = animations.filter((animation) => animation.effect?.getTiming().iterations !== Infinity); if (finiteAnimations.some((animation) => animation.playState === 'running' || animation.playState === 'pending')) { schedule(); return; } finished = true; cleanup(); resolve(true); }; const schedule = () => { clearTimeout(quietTimer); quietTimer = setTimeout(finish, quietMs); }; const observeRoot = (root) => { if (!observedRoots.has(root)) { observedRoots.add(root); observer.observe(root, { subtree: true, childList: true, attributes: true, characterData: true }); schedule(); } for (const element of root.querySelectorAll('*')) { if (element.shadowRoot) observeRoot(element.shadowRoot); } }; const discoverRoots = () => observeRoot(document); observer = new MutationObserver(() => { discoverRoots(); schedule(); }); discoverRoots(); rootPoll = setInterval(discoverRoots, Math.min(25, quietMs)); requestAnimationFrame(() => requestAnimationFrame(schedule)); timeout = setTimeout(() => { if (finished) return; finished = true; cleanup(); reject(new Error('DOM remained active for this settlement attempt.')); }, ${attemptTimeoutMs}); })`;
|
|
483
|
+
const settled = await withTimeout(evaluateCdpSessionInIsolatedWorld(this.session, expression, DOM_SETTLEMENT_WORLD, attemptTimeoutMs + 25), attemptTimeoutMs + 50, timeoutReason);
|
|
484
|
+
if (!settled)
|
|
485
|
+
throw new Error(supersededReason);
|
|
486
|
+
}, quietMs);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
if (error instanceof Error &&
|
|
490
|
+
(error.message === timeoutReason || error.message === supersededReason)) {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
throw new Error(`${timeoutReason}: ${error instanceof Error ? error.message : String(error)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async evaluate(expression) {
|
|
497
|
+
const result = await this.session.call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
|
498
|
+
if (result.exceptionDetails) {
|
|
499
|
+
throw new Error(result.exceptionDetails.exception?.description ??
|
|
500
|
+
result.exceptionDetails.text ??
|
|
501
|
+
'CDP evaluation failed.');
|
|
502
|
+
}
|
|
503
|
+
return result.result?.value;
|
|
504
|
+
}
|
|
505
|
+
async evaluateInIsolatedWorld(expression, worldName) {
|
|
506
|
+
try {
|
|
507
|
+
return await evaluateCdpSessionInIsolatedWorld(this.session, expression, worldName);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (!isTransientCdpContextError(error))
|
|
511
|
+
throw error;
|
|
512
|
+
return evaluateCdpSessionInIsolatedWorld(this.session, expression, worldName);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async click(selector) {
|
|
516
|
+
const target = await this.waitForClickTarget(`(() => { ${deepQueryHelpersExpression()} const el = querySelectorDeep(${JSON.stringify(selector)}); if (!el) throw new Error('Selector not found: ${escapeForJsMessage(selector)}'); el.scrollIntoView({ block: 'center', inline: 'center' }); const point = clickablePointDeep(el, true); const rect = el.getBoundingClientRect(); return point ? { ...point, width: rect.width, height: rect.height, selector: ${JSON.stringify(selector)}, tagName: el.tagName } : null; })()`, selector);
|
|
517
|
+
await this.pressAndReleasePoint(target.x, target.y);
|
|
518
|
+
return { clicked: true, selector: target.selector, tagName: target.tagName };
|
|
519
|
+
}
|
|
520
|
+
async clickText(text) {
|
|
521
|
+
const target = await this.waitForClickTarget(`(() => { ${deepQueryHelpersExpression()} const expected = ${JSON.stringify(text)}; const candidates = querySelectorAllDeep('button, [role=button], a, label, input, textarea, [tabindex]'); const el = candidates.find((candidate) => (candidate.innerText || candidate.textContent || candidate.getAttribute('aria-label') || candidate.getAttribute('value') || '').trim().includes(expected)); if (!el) throw new Error('Text target not found: ${escapeForJsMessage(text)}'); el.scrollIntoView({ block: 'center', inline: 'center' }); const point = clickablePointDeep(el, true); const rect = el.getBoundingClientRect(); return point ? { ...point, width: rect.width, height: rect.height, text: expected, tagName: el.tagName } : null; })()`, text);
|
|
522
|
+
await this.pressAndReleasePoint(target.x, target.y);
|
|
523
|
+
return { clicked: true, text: target.text, tagName: target.tagName };
|
|
524
|
+
}
|
|
525
|
+
async waitForClickTarget(expression, label) {
|
|
526
|
+
const deadline = Date.now() + 1_000;
|
|
527
|
+
let previousGeometry;
|
|
528
|
+
let stableSince = 0;
|
|
529
|
+
while (true) {
|
|
530
|
+
const remainingMs = deadline - Date.now();
|
|
531
|
+
if (remainingMs <= 0) {
|
|
532
|
+
throw new Error(`Click target readiness timed out: ${label}`);
|
|
533
|
+
}
|
|
534
|
+
let target;
|
|
535
|
+
try {
|
|
536
|
+
target = await withTimeout(retryTransientCdpContext(deadline, (attemptTimeoutMs) => evaluateCdpSessionInIsolatedWorld(this.session, expression, 'farmslot-click-readiness', attemptTimeoutMs)), remainingMs, `Click target readiness timed out: ${label}`);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
// The per-call timer can expire before the enclosing readiness timer.
|
|
540
|
+
if (error instanceof CdpCallTimeoutError) {
|
|
541
|
+
throw new Error(`Click target readiness timed out: ${label}`, { cause: error });
|
|
542
|
+
}
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
if (target === null) {
|
|
546
|
+
previousGeometry = undefined;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
const geometry = JSON.stringify(target);
|
|
550
|
+
if (geometry !== previousGeometry) {
|
|
551
|
+
await this.session.call('Input.dispatchMouseEvent', {
|
|
552
|
+
type: 'mouseMoved',
|
|
553
|
+
x: target.x,
|
|
554
|
+
y: target.y,
|
|
555
|
+
button: 'none',
|
|
556
|
+
}, { timeoutMs: Math.max(1, deadline - Date.now()) });
|
|
557
|
+
previousGeometry = geometry;
|
|
558
|
+
stableSince = Date.now();
|
|
559
|
+
}
|
|
560
|
+
else if (Date.now() - stableSince >= 50 && Date.now() < deadline) {
|
|
561
|
+
// Sample unchanged geometry and an unobstructed hit after hover, before pressing.
|
|
562
|
+
return target;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const pauseMs = deadline - Date.now();
|
|
566
|
+
if (pauseMs <= 0) {
|
|
567
|
+
throw new Error(`Click target readiness timed out: ${label}`);
|
|
568
|
+
}
|
|
569
|
+
await sleep(Math.min(25, pauseMs));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async setInput(selector, value) {
|
|
573
|
+
const before = await this.evaluate(`(() => { ${deepQueryHelpersExpression()} const root = querySelectorDeep(${JSON.stringify(selector)}); if (!root) throw new Error('Selector not found: ${escapeForJsMessage(selector)}'); const el = root.matches('input, textarea, [contenteditable="true"], [contenteditable=""]') ? root : querySelectorDeep('input, textarea, [contenteditable="true"], [contenteditable=""]', root); if (!el) throw new Error('Input target not found inside selector: ${escapeForJsMessage(selector)}'); if (el.disabled || el.getAttribute('aria-disabled') === 'true') throw new Error('Input target is disabled: ${escapeForJsMessage(selector)}'); el.scrollIntoView({ block: 'center', inline: 'nearest' }); el.focus(); if (typeof el.select === 'function') { el.select(); } else { const range = document.createRange(); range.selectNodeContents(el); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } return { selector: ${JSON.stringify(selector)}, tagName: el.tagName, previousValue: el.value ?? el.textContent ?? '' }; })()`);
|
|
574
|
+
await this.session.call('Input.dispatchKeyEvent', {
|
|
575
|
+
type: 'keyDown',
|
|
576
|
+
key: 'Backspace',
|
|
577
|
+
windowsVirtualKeyCode: 8,
|
|
578
|
+
});
|
|
579
|
+
await this.session.call('Input.dispatchKeyEvent', {
|
|
580
|
+
type: 'keyUp',
|
|
581
|
+
key: 'Backspace',
|
|
582
|
+
windowsVirtualKeyCode: 8,
|
|
583
|
+
});
|
|
584
|
+
if (value)
|
|
585
|
+
await this.session.call('Input.insertText', { text: value });
|
|
586
|
+
const after = await this.evaluate(`(() => { ${deepQueryHelpersExpression()} const root = querySelectorDeep(${JSON.stringify(selector)}); const el = root && (root.matches('input, textarea, [contenteditable="true"], [contenteditable=""]') ? root : querySelectorDeep('input, textarea, [contenteditable="true"], [contenteditable=""]', root)); if (!el) throw new Error('Input target disappeared after typing: ${escapeForJsMessage(selector)}'); el.dispatchEvent(new Event('change', { bubbles: true })); return { value: el.value ?? el.textContent ?? '' }; })()`);
|
|
587
|
+
if (after.value !== value) {
|
|
588
|
+
throw new Error(`Input target value mismatch after typing ${selector}: expected ${JSON.stringify(value)}, got ${JSON.stringify(after.value)}.`);
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
set: true,
|
|
592
|
+
selector: before.selector,
|
|
593
|
+
tagName: before.tagName,
|
|
594
|
+
previousValue: before.previousValue,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async keyPress(key) {
|
|
598
|
+
await this.session.call('Input.dispatchKeyEvent', { type: 'keyDown', key });
|
|
599
|
+
await this.session.call('Input.dispatchKeyEvent', { type: 'keyUp', key });
|
|
600
|
+
return { key };
|
|
601
|
+
}
|
|
602
|
+
async clickPoint(x, y) {
|
|
603
|
+
await this.session.call('Input.dispatchMouseEvent', {
|
|
604
|
+
type: 'mouseMoved',
|
|
605
|
+
x,
|
|
606
|
+
y,
|
|
607
|
+
button: 'none',
|
|
608
|
+
});
|
|
609
|
+
await this.pressAndReleasePoint(x, y);
|
|
610
|
+
}
|
|
611
|
+
async pressAndReleasePoint(x, y) {
|
|
612
|
+
await this.session.call('Input.dispatchMouseEvent', {
|
|
613
|
+
type: 'mousePressed',
|
|
614
|
+
x,
|
|
615
|
+
y,
|
|
616
|
+
button: 'left',
|
|
617
|
+
buttons: 1,
|
|
618
|
+
clickCount: 1,
|
|
619
|
+
});
|
|
620
|
+
await this.session.call('Input.dispatchMouseEvent', {
|
|
621
|
+
type: 'mouseReleased',
|
|
622
|
+
x,
|
|
623
|
+
y,
|
|
624
|
+
button: 'left',
|
|
625
|
+
buttons: 0,
|
|
626
|
+
clickCount: 1,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
async resolveGesturePoint(target) {
|
|
630
|
+
if (typeof target !== 'string')
|
|
631
|
+
return target;
|
|
632
|
+
const selector = dataTestId(target);
|
|
633
|
+
return this.evaluate(`(() => { ${deepQueryHelpersExpression()} const el = querySelectorDeep(${JSON.stringify(selector)}); if (!el) throw new Error('Gesture target not found: ${escapeForJsMessage(target)}'); el.scrollIntoView({ block: 'center', inline: 'center' }); return clickablePointDeep(el); })()`);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Raw ui.scroll movement. offsetX/offsetY set an absolute position; deltaX/deltaY move relative
|
|
637
|
+
* to the current one (default: one 600px step down). Callers never mix the two.
|
|
638
|
+
*/
|
|
639
|
+
async scroll(options) {
|
|
640
|
+
if (options.selector && options.intoView) {
|
|
641
|
+
return this.evaluate(`(() => { ${deepQueryHelpersExpression()} const el = querySelectorDeep(${JSON.stringify(options.selector)}); if (!el) throw new Error('Selector not found: ${escapeForJsMessage(options.selector)}'); el.scrollIntoView({ block: 'center', inline: 'nearest' }); return { scrolled: true, selector: ${JSON.stringify(options.selector)}, intoView: true }; })()`);
|
|
642
|
+
}
|
|
643
|
+
const absolute = options.offsetX !== undefined || options.offsetY !== undefined;
|
|
644
|
+
const noDelta = options.deltaX === undefined && options.deltaY === undefined;
|
|
645
|
+
const move = absolute
|
|
646
|
+
? `el.scrollTo({ left: ${JSON.stringify(options.offsetX ?? null)} ?? el.scrollLeft, top: ${JSON.stringify(options.offsetY ?? null)} ?? el.scrollTop, behavior: 'instant' })`
|
|
647
|
+
: `el.scrollBy({ left: ${JSON.stringify(options.deltaX ?? 0)}, top: ${JSON.stringify(noDelta ? 600 : (options.deltaY ?? 0))}, behavior: 'instant' })`;
|
|
648
|
+
const resolveElement = options.selector
|
|
649
|
+
? `${deepQueryHelpersExpression()} const el = querySelectorDeep(${JSON.stringify(options.selector)}); if (!el) throw new Error('Selector not found: ${escapeForJsMessage(options.selector)}');`
|
|
650
|
+
: 'const el = document.scrollingElement || document.documentElement;';
|
|
651
|
+
return this.evaluate(`(() => { ${resolveElement} const before = { x: el.scrollLeft, y: el.scrollTop }; ${move}; return { scrolled: true, mode: ${JSON.stringify(absolute ? 'absolute' : 'relative')}, before, after: { x: el.scrollLeft, y: el.scrollTop } }; })()`);
|
|
652
|
+
}
|
|
653
|
+
/** ui.scroll_to primitives for one surface; valid while this page connection stays open. */
|
|
654
|
+
async scrollSession(request) {
|
|
655
|
+
const { targetInfo } = await this.session.call('Target.getTargetInfo');
|
|
656
|
+
const surface = testIdSelector(request.surfaceTestId);
|
|
657
|
+
const measure = scrollMeasureExpression(surface, testIdSelector(request.targetTestId), request.visibilityAnchorTestId ? testIdSelector(request.visibilityAnchorTestId) : undefined);
|
|
658
|
+
return {
|
|
659
|
+
backend: 'cdp-web',
|
|
660
|
+
sessionId: targetInfo.targetId,
|
|
661
|
+
measure: () => this.evaluate(measure),
|
|
662
|
+
scrollTo: async (offset) => {
|
|
663
|
+
const moved = await this.evaluate(`(() => { ${deepQueryHelpersExpression()} const found = querySelectorDeep(${JSON.stringify(surface)}); if (!found) return false; const bodyScrolls = found === document.body && /(auto|scroll)/.test(getComputedStyle(document.body).overflowY) && getComputedStyle(document.documentElement).overflowY !== 'visible'; const el = !bodyScrolls && (found === document.body || found === document.documentElement) ? document.scrollingElement || document.documentElement : found; el.scrollTo({ left: ${JSON.stringify(offset.x)}, top: ${JSON.stringify(offset.y)}, behavior: 'instant' }); return true; })()`);
|
|
664
|
+
if (!moved) {
|
|
665
|
+
throw new UiScrollToError('SCROLL_SURFACE_MISSING', `surface ${request.surfaceTestId} unmounted before the move.`);
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
async waitFor(options) {
|
|
671
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
672
|
+
return this.evaluate(`(async () => { ${deepQueryHelpersExpression()} const deadline = Date.now() + ${JSON.stringify(timeoutMs)}; while (Date.now() <= deadline) { const ok = ${waitForPredicateExpression(options)}; if (ok) return { matched: true }; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('ui.wait_for timed out'); })()`);
|
|
673
|
+
}
|
|
674
|
+
async waitForSelector(selector, options = {}) {
|
|
675
|
+
return this.waitFor({ selector, timeoutMs: options.timeoutMs });
|
|
676
|
+
}
|
|
677
|
+
async waitForExpression(expression, options = {}) {
|
|
678
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
679
|
+
return this.evaluate(`(async () => { const deadline = Date.now() + ${JSON.stringify(timeoutMs)}; while (Date.now() <= deadline) { if (await (${expression})) return { matched: true }; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('waitForExpression timed out'); })()`);
|
|
680
|
+
}
|
|
681
|
+
async screenshot(context, relPath, metadata = {}) {
|
|
682
|
+
const timeoutMs = typeof metadata.timeoutMs === 'number' ? metadata.timeoutMs : 30_000;
|
|
683
|
+
const fullPage = metadata.fullPage === true;
|
|
684
|
+
let clip;
|
|
685
|
+
if (fullPage) {
|
|
686
|
+
const metrics = await this.session.call('Page.getLayoutMetrics');
|
|
687
|
+
const contentSize = metrics.cssContentSize ?? metrics.contentSize;
|
|
688
|
+
if (typeof contentSize?.width !== 'number' || typeof contentSize.height !== 'number') {
|
|
689
|
+
throw new Error('CDP did not report the full page dimensions.');
|
|
690
|
+
}
|
|
691
|
+
clip = { x: 0, y: 0, width: contentSize.width, height: contentSize.height, scale: 1 };
|
|
692
|
+
}
|
|
693
|
+
const result = await withTimeout(this.session.call('Page.captureScreenshot', {
|
|
694
|
+
format: 'png',
|
|
695
|
+
...(clip ? { captureBeyondViewport: true, clip } : {}),
|
|
696
|
+
}), timeoutMs, `Page.captureScreenshot timed out after ${timeoutMs}ms`);
|
|
697
|
+
if (!result.data)
|
|
698
|
+
throw new Error('CDP screenshot did not return data.');
|
|
699
|
+
if (!context)
|
|
700
|
+
return result.data;
|
|
701
|
+
const relativePath = relPath ?? `screenshots/${context.nodeId}.png`;
|
|
702
|
+
await writeFileWithinRoot(context.artifactsDir, relativePath, Buffer.from(result.data, 'base64'));
|
|
703
|
+
return {
|
|
704
|
+
path: relativePath,
|
|
705
|
+
type: 'screenshot',
|
|
706
|
+
nodeId: context.nodeId,
|
|
707
|
+
label: typeof metadata.label === 'string' ? metadata.label : undefined,
|
|
708
|
+
category: typeof metadata.category === 'string' ? metadata.category : 'evidence',
|
|
709
|
+
mimeType: 'image/png',
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async observe(refs) {
|
|
713
|
+
const observations = {};
|
|
714
|
+
const warnings = [];
|
|
715
|
+
for (const ref of refs) {
|
|
716
|
+
try {
|
|
717
|
+
if (ref === 'ui.screen') {
|
|
718
|
+
observations[ref] = await this.evaluate(`(() => { const hashPath = location.hash.split('?')[0]; const hashRoute = hashPath && !hashPath.includes('=') ? hashPath : undefined; return { provider: 'cdp-web', name: document.title || location.pathname || hashRoute || 'Browser', title: document.title || undefined, route: hashRoute || location.pathname || undefined, url: location.origin + location.pathname }; })()`);
|
|
719
|
+
}
|
|
720
|
+
else if (ref === 'ui.visible') {
|
|
721
|
+
observations[ref] = await this.evaluate(visibleTargetsExpression());
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
warnings.push({ ref, message: `Unsupported UI observer: ${ref}.` });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
warnings.push({
|
|
729
|
+
ref,
|
|
730
|
+
message: error instanceof Error ? error.message : String(error),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
...(Object.keys(observations).length ? { observations } : {}),
|
|
736
|
+
...(warnings.length ? { warnings } : {}),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
close() {
|
|
740
|
+
this.session.close();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
744
|
+
let timeout;
|
|
745
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
746
|
+
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
747
|
+
});
|
|
748
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
749
|
+
if (timeout)
|
|
750
|
+
clearTimeout(timeout);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
function isTransientCdpContextError(error) {
|
|
754
|
+
if (error instanceof CdpPageEvaluationError)
|
|
755
|
+
return false;
|
|
756
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
757
|
+
return /execution context was destroyed|execution context with given id not found|cannot find context with specified id|execution context is not available in detached frame|inspected target navigated or closed|not attached to an active page|session with given id not found|no frame (?:with|for) given id found|frame with the given id (?:is|was) not found/iu.test(message);
|
|
758
|
+
}
|
|
759
|
+
export function createCdpWebUiTransport(options) {
|
|
760
|
+
return {
|
|
761
|
+
async execute(action, node, context) {
|
|
762
|
+
const input = { action, node, context };
|
|
763
|
+
return withCdpWebPage(options, input, async (page) => {
|
|
764
|
+
switch (action) {
|
|
765
|
+
case 'ui.navigate': {
|
|
766
|
+
const url = asString(node.url ?? node.target, 'ui.navigate.url');
|
|
767
|
+
return executeSettledCdpAction(page, page.navigate(url, node.timeout_ms == null
|
|
768
|
+
? undefined
|
|
769
|
+
: asNumber(node.timeout_ms, 'ui.navigate.timeout_ms')), node);
|
|
770
|
+
}
|
|
771
|
+
case 'ui.press': {
|
|
772
|
+
const selector = selectorForUiInput(node);
|
|
773
|
+
return executeSettledCdpAction(page, selector
|
|
774
|
+
? page.click(selector)
|
|
775
|
+
: page.clickText(asString(node.text ?? node.label, 'ui.press.text')), node);
|
|
776
|
+
}
|
|
777
|
+
case 'ui.key_press':
|
|
778
|
+
return executeSettledCdpAction(page, page.keyPress(asString(node.key, 'ui.key_press.key')), node);
|
|
779
|
+
case 'ui.set_input':
|
|
780
|
+
return executeSettledCdpAction(page, page.setInput(asString(selectorForUiInput(node), 'ui.set_input.selector'), asInputText(node.value ?? node.text, 'ui.set_input.value')), node);
|
|
781
|
+
case 'ui.scroll':
|
|
782
|
+
return executeSettledCdpAction(page, page.scroll({
|
|
783
|
+
selector: asOptionalString(selectorForUiInput(node), 'ui.scroll.selector'),
|
|
784
|
+
intoView: node.scroll_into_view === true || node.into_view === true,
|
|
785
|
+
offsetX: node.offset_x == null ? undefined : asNumber(node.offset_x, 'ui.scroll.offset_x'),
|
|
786
|
+
offsetY: node.offset_y == null ? undefined : asNumber(node.offset_y, 'ui.scroll.offset_y'),
|
|
787
|
+
deltaX: node.delta_x == null ? undefined : asNumber(node.delta_x, 'ui.scroll.delta_x'),
|
|
788
|
+
deltaY: node.delta_y == null ? undefined : asNumber(node.delta_y, 'ui.scroll.delta_y'),
|
|
789
|
+
}), node);
|
|
790
|
+
case 'ui.scroll_to':
|
|
791
|
+
throw new Error('ui.scroll_to runs through withScrollSession, not execute.');
|
|
792
|
+
case 'ui.swipe':
|
|
793
|
+
case 'ui.pan':
|
|
794
|
+
case 'ui.drag':
|
|
795
|
+
case 'ui.long_press':
|
|
796
|
+
return executeSettledCdpAction(page, executeCdpGesture(page, action, node), node);
|
|
797
|
+
case 'ui.wait_for':
|
|
798
|
+
return executeSettledCdpAction(page, page.waitFor({
|
|
799
|
+
selector: asOptionalString(selectorForUiInput(node), 'ui.wait_for.selector'),
|
|
800
|
+
text: asOptionalString(node.text, 'ui.wait_for.text'),
|
|
801
|
+
expected: asOptionalString(node.expected, 'ui.wait_for.expected'),
|
|
802
|
+
timeoutMs: node.timeout_ms == null
|
|
803
|
+
? undefined
|
|
804
|
+
: asNumber(node.timeout_ms, 'ui.wait_for.timeout_ms'),
|
|
805
|
+
}), node);
|
|
806
|
+
case 'ui.screenshot':
|
|
807
|
+
return captureCdpScreenshot(page, node, context);
|
|
808
|
+
case 'ui.capture_surface':
|
|
809
|
+
return captureCdpScreenshot(page, node, context, true);
|
|
810
|
+
case 'app.status':
|
|
811
|
+
return page.evaluate('(() => ({ url: location.href, title: document.title }))()');
|
|
812
|
+
case 'app.lifecycle':
|
|
813
|
+
return runCdpLifecycle(page, node);
|
|
814
|
+
case 'app.hud':
|
|
815
|
+
return renderCdpHud(page, node, context);
|
|
816
|
+
case 'app.trace':
|
|
817
|
+
return page.evaluate('(() => ({ entries: performance.getEntries().slice(-20).map((entry) => ({ name: entry.name, type: entry.entryType, startTime: entry.startTime, duration: entry.duration })) }))()');
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
async observe(refs, node, context) {
|
|
822
|
+
const input = { action: 'app.status', node, context };
|
|
823
|
+
return withCdpWebPage(options, input, async (page) => page.observe(refs));
|
|
824
|
+
},
|
|
825
|
+
async withScrollSession(request, node, context, use) {
|
|
826
|
+
const input = { action: 'ui.scroll_to', node, context };
|
|
827
|
+
return withCdpWebPage(options, input, async (page) => use(await page.scrollSession(request)));
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async function executeCdpGesture(page, action, node) {
|
|
832
|
+
const start = await page.resolveGesturePoint(gestureTarget(node, action));
|
|
833
|
+
const durationMs = gestureDurationMs(node, action);
|
|
834
|
+
const points = gesturePoints(action, node, start);
|
|
835
|
+
const segmentDurationMs = gestureSegmentDuration(durationMs, points);
|
|
836
|
+
const pointer = (await page.evaluate('navigator.maxTouchPoints || 0')) > 0 ? 'touch' : 'mouse';
|
|
837
|
+
const phases = [];
|
|
838
|
+
const startedAtMs = Date.now();
|
|
839
|
+
if (pointer === 'touch') {
|
|
840
|
+
await page.session.call('Input.dispatchTouchEvent', {
|
|
841
|
+
type: 'touchStart',
|
|
842
|
+
touchPoints: [{ ...start, radiusX: 1, radiusY: 1 }],
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
847
|
+
type: 'mouseMoved',
|
|
848
|
+
...start,
|
|
849
|
+
button: 'none',
|
|
850
|
+
});
|
|
851
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
852
|
+
type: 'mousePressed',
|
|
853
|
+
...start,
|
|
854
|
+
button: 'left',
|
|
855
|
+
buttons: 1,
|
|
856
|
+
clickCount: 1,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
phases.push(gesturePhase('start', start, startedAtMs));
|
|
860
|
+
for (const point of points.slice(1)) {
|
|
861
|
+
await sleep(segmentDurationMs);
|
|
862
|
+
if (pointer === 'touch') {
|
|
863
|
+
await page.session.call('Input.dispatchTouchEvent', {
|
|
864
|
+
type: 'touchMove',
|
|
865
|
+
touchPoints: [{ ...point, radiusX: 1, radiusY: 1 }],
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
870
|
+
type: 'mouseMoved',
|
|
871
|
+
...point,
|
|
872
|
+
button: 'left',
|
|
873
|
+
buttons: 1,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
phases.push(gesturePhase('move', point, startedAtMs));
|
|
877
|
+
}
|
|
878
|
+
const end = points.at(-1);
|
|
879
|
+
if (pointer === 'touch') {
|
|
880
|
+
await page.session.call('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
await page.session.call('Input.dispatchMouseEvent', {
|
|
884
|
+
type: 'mouseReleased',
|
|
885
|
+
...end,
|
|
886
|
+
button: 'left',
|
|
887
|
+
buttons: 0,
|
|
888
|
+
clickCount: 1,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
phases.push(gesturePhase('end', end, startedAtMs));
|
|
892
|
+
return {
|
|
893
|
+
kind: 'ui-transport-result',
|
|
894
|
+
output: { action, pointer, resolvedStart: start, resolvedEnd: end },
|
|
895
|
+
phases,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function asInputText(value, label) {
|
|
899
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
900
|
+
return String(value);
|
|
901
|
+
return asString(value, label);
|
|
902
|
+
}
|
|
903
|
+
async function executeSettledCdpAction(page, operation, node) {
|
|
904
|
+
const startedAt = Date.now();
|
|
905
|
+
const result = await operation;
|
|
906
|
+
if (node.settle === false)
|
|
907
|
+
return result;
|
|
908
|
+
// timeout_ms budgets the whole node: settlement only gets what the action left over.
|
|
909
|
+
const timeoutMs = node.timeout_ms == null
|
|
910
|
+
? undefined
|
|
911
|
+
: Math.max(1, asNumber(node.timeout_ms, 'ui action timeout_ms') - (Date.now() - startedAt));
|
|
912
|
+
try {
|
|
913
|
+
await page.waitForDomSettled(timeoutMs);
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
const settlementWarning = error instanceof Error ? error.message : String(error);
|
|
918
|
+
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
|
919
|
+
return { ...result, settlementWarning };
|
|
920
|
+
}
|
|
921
|
+
return { result, settlementWarning };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function visibleTargetsExpression() {
|
|
925
|
+
return `(() => {
|
|
926
|
+
const visibleLimit = 20;
|
|
927
|
+
const hiddenLimit = 10;
|
|
928
|
+
const selector = [
|
|
929
|
+
'button',
|
|
930
|
+
'a[href]',
|
|
931
|
+
'input',
|
|
932
|
+
'textarea',
|
|
933
|
+
'select',
|
|
934
|
+
'[role="button"]',
|
|
935
|
+
'[role="link"]',
|
|
936
|
+
'[role="menuitem"]',
|
|
937
|
+
'[role="tab"]'
|
|
938
|
+
].join(',');
|
|
939
|
+
${deepQueryHelpersExpression()}
|
|
940
|
+
const nodes = querySelectorAllDeep(selector);
|
|
941
|
+
const textFor = (el) => {
|
|
942
|
+
const raw = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || el.innerText || '';
|
|
943
|
+
return String(raw).replace(/\\s+/g, ' ').trim().slice(0, 120) || undefined;
|
|
944
|
+
};
|
|
945
|
+
const cssString = (value) => '"' + Array.from(String(value), (character) => {
|
|
946
|
+
const code = character.codePointAt(0);
|
|
947
|
+
if (character === '"' || character === '\\\\') return '\\\\' + character;
|
|
948
|
+
if (code === 0) return '\\uFFFD';
|
|
949
|
+
if (code <= 0x1f || code === 0x7f) return '\\\\' + code.toString(16) + ' ';
|
|
950
|
+
return character;
|
|
951
|
+
}).join('') + '"';
|
|
952
|
+
const cssPath = (el) => {
|
|
953
|
+
if (el.id) return '[id=' + cssString(el.id) + ']';
|
|
954
|
+
const testAttribute = ['data-testid', 'data-test-id', 'data-test'].find((attribute) => el.hasAttribute(attribute));
|
|
955
|
+
if (testAttribute) {
|
|
956
|
+
return '[' + testAttribute + '=' + cssString(el.getAttribute(testAttribute)) + ']';
|
|
957
|
+
}
|
|
958
|
+
if (shadowHostFor(el.getRootNode())) return undefined;
|
|
959
|
+
const parts = [];
|
|
960
|
+
let current = el;
|
|
961
|
+
while (current && current.nodeType === 1) {
|
|
962
|
+
if (current.id) {
|
|
963
|
+
parts.unshift('[id=' + cssString(current.id) + ']');
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
const tag = current.tagName.toLowerCase();
|
|
967
|
+
const parent = current.parentElement;
|
|
968
|
+
if (!parent) {
|
|
969
|
+
parts.unshift(tag);
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
|
|
973
|
+
parts.unshift(siblings.length > 1 ? tag + ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')' : tag);
|
|
974
|
+
current = parent;
|
|
975
|
+
}
|
|
976
|
+
return parts.join(' > ');
|
|
977
|
+
};
|
|
978
|
+
const itemFor = (el, rect, includeLabel) => ({
|
|
979
|
+
role: el.getAttribute('role') || (el.tagName.toLowerCase() === 'a' ? 'link' : el.tagName.toLowerCase() === 'button' ? 'button' : undefined),
|
|
980
|
+
label: includeLabel ? textFor(el) : undefined,
|
|
981
|
+
test_id: el.getAttribute('data-testid') || el.getAttribute('data-test-id') || el.getAttribute('data-test') || undefined,
|
|
982
|
+
selector: cssPath(el),
|
|
983
|
+
enabled: !(el.disabled || el.getAttribute('aria-disabled') === 'true'),
|
|
984
|
+
selected: el.getAttribute('aria-selected') === 'true' || undefined,
|
|
985
|
+
focused: document.activeElement === el || undefined,
|
|
986
|
+
bounds: rect ? { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) } : undefined
|
|
987
|
+
});
|
|
988
|
+
const composedContains = (ancestor, descendant) => {
|
|
989
|
+
let current = descendant;
|
|
990
|
+
while (current) {
|
|
991
|
+
if (current === ancestor) return true;
|
|
992
|
+
const root = current.getRootNode();
|
|
993
|
+
current = current.parentElement || shadowHostFor(root);
|
|
994
|
+
}
|
|
995
|
+
return false;
|
|
996
|
+
};
|
|
997
|
+
const isCoveredDeep = (el, rect) => {
|
|
998
|
+
const x = rect.left + rect.width / 2;
|
|
999
|
+
const y = rect.top + rect.height / 2;
|
|
1000
|
+
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return false;
|
|
1001
|
+
let hit = document.elementFromPoint(x, y);
|
|
1002
|
+
while (hit && hit.shadowRoot) {
|
|
1003
|
+
const deeper = hit.shadowRoot.elementFromPoint(x, y);
|
|
1004
|
+
if (!deeper || deeper === hit) break;
|
|
1005
|
+
hit = deeper;
|
|
1006
|
+
}
|
|
1007
|
+
if (!hit) return false;
|
|
1008
|
+
return !composedContains(el, hit);
|
|
1009
|
+
};
|
|
1010
|
+
const items = [];
|
|
1011
|
+
const hidden_or_offscreen = [];
|
|
1012
|
+
for (const el of nodes) {
|
|
1013
|
+
const rect = el.getBoundingClientRect();
|
|
1014
|
+
const hasBox = rect.width > 0 && rect.height > 0;
|
|
1015
|
+
const rendered = hasBox && isRenderedDeep(el);
|
|
1016
|
+
const visible = rendered && isVisibleDeep(el);
|
|
1017
|
+
const covered = visible && isCoveredDeep(el, rect);
|
|
1018
|
+
if (visible && !covered && items.length < visibleLimit) {
|
|
1019
|
+
items.push(itemFor(el, rect, true));
|
|
1020
|
+
} else if ((!visible || covered) && hidden_or_offscreen.length < hiddenLimit) {
|
|
1021
|
+
hidden_or_offscreen.push({ ...itemFor(el, rendered ? rect : undefined, rendered), reason: covered ? 'covered' : rendered ? 'offscreen' : 'hidden_or_no_box' });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
provider: 'cdp-web',
|
|
1026
|
+
items,
|
|
1027
|
+
hidden_or_offscreen,
|
|
1028
|
+
truncated: nodes.length > items.length + hidden_or_offscreen.length,
|
|
1029
|
+
limits: { items: visibleLimit, hidden_or_offscreen: hiddenLimit }
|
|
1030
|
+
};
|
|
1031
|
+
})()`;
|
|
1032
|
+
}
|
|
1033
|
+
async function withCdpWebPage(options, input, callback) {
|
|
1034
|
+
if (options.withPage)
|
|
1035
|
+
return options.withPage(input, callback);
|
|
1036
|
+
if (!options.getPage)
|
|
1037
|
+
throw new Error('CDP web UI transport requires getPage or withPage.');
|
|
1038
|
+
const page = await options.getPage(input);
|
|
1039
|
+
try {
|
|
1040
|
+
return await callback(page);
|
|
1041
|
+
}
|
|
1042
|
+
finally {
|
|
1043
|
+
page.close();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
export function selectorForUiInput(node) {
|
|
1047
|
+
const selector = asOptionalString(node.selector, 'ui.selector');
|
|
1048
|
+
if (selector)
|
|
1049
|
+
return selector;
|
|
1050
|
+
const testId = asOptionalString(node.test_id ?? node.testID, 'ui.test_id');
|
|
1051
|
+
return testId ? testIdSelector(testId) : undefined;
|
|
1052
|
+
}
|
|
1053
|
+
function testIdSelector(testId) {
|
|
1054
|
+
const escaped = escapeCssAttrValue(testId);
|
|
1055
|
+
return `[data-testid="${escaped}"], [data-test-id="${escaped}"], [data-test="${escaped}"]`;
|
|
1056
|
+
}
|
|
1057
|
+
// Geometry for ui.scroll_to in viewport CSS pixels. A box-less or unrendered element (display:
|
|
1058
|
+
// contents, zero size, visibility hidden, opacity 0) is present but not measurable. The recipe
|
|
1059
|
+
// HUD is the page's known occlusion.
|
|
1060
|
+
function scrollMeasureExpression(surface, target, anchor) {
|
|
1061
|
+
return `(() => {
|
|
1062
|
+
${deepQueryHelpersExpression()}
|
|
1063
|
+
const box = (el) => {
|
|
1064
|
+
if (!isRenderedDeep(el)) return null;
|
|
1065
|
+
const r = el.getBoundingClientRect();
|
|
1066
|
+
return r.width > 0 && r.height > 0 ? { x: r.x, y: r.y, width: r.width, height: r.height } : null;
|
|
1067
|
+
};
|
|
1068
|
+
const targetEl = querySelectorDeep(${JSON.stringify(target)});
|
|
1069
|
+
const measured = { targetPresent: Boolean(targetEl), targetBounds: box(targetEl) };
|
|
1070
|
+
${anchor
|
|
1071
|
+
? `const anchorEl = querySelectorDeep(${JSON.stringify(anchor)});
|
|
1072
|
+
measured.visibilityAnchorPresent = Boolean(anchorEl);
|
|
1073
|
+
measured.visibilityAnchorBounds = box(anchorEl);`
|
|
1074
|
+
: ''}
|
|
1075
|
+
const el = querySelectorDeep(${JSON.stringify(surface)});
|
|
1076
|
+
if (!el) return { surface: null, viewport: null, offset: { x: 0, y: 0 }, ...measured };
|
|
1077
|
+
const rect = el.getBoundingClientRect();
|
|
1078
|
+
// <body> is its own scroller only when it scrolls and <html> does not pass overflow through.
|
|
1079
|
+
const bodyScrolls = el === document.body && /(auto|scroll)/.test(getComputedStyle(el).overflowY) && getComputedStyle(document.documentElement).overflowY !== 'visible';
|
|
1080
|
+
const isRoot = !bodyScrolls && (el === document.scrollingElement || el === document.body || el === document.documentElement);
|
|
1081
|
+
const scroller = isRoot ? document.scrollingElement || document.documentElement : el;
|
|
1082
|
+
let left = isRoot ? 0 : Math.max(rect.x + el.clientLeft, 0);
|
|
1083
|
+
let top = isRoot ? 0 : Math.max(rect.y + el.clientTop, 0);
|
|
1084
|
+
// clientWidth/clientHeight of <html> exclude the page scrollbars.
|
|
1085
|
+
const windowWidth = document.documentElement.clientWidth || innerWidth;
|
|
1086
|
+
const windowHeight = document.documentElement.clientHeight || innerHeight;
|
|
1087
|
+
let right = isRoot ? windowWidth : Math.min(rect.x + el.clientLeft + el.clientWidth, windowWidth);
|
|
1088
|
+
let bottom = isRoot ? windowHeight : Math.min(rect.y + el.clientTop + el.clientHeight, windowHeight);
|
|
1089
|
+
// An outer overflow container can clip part of the surface; only the unclipped part is visible.
|
|
1090
|
+
for (let clip = isRoot ? null : el.parentElement || shadowHostFor(el.getRootNode()); clip && clip !== document.documentElement && clip !== document.body; clip = clip.parentElement || shadowHostFor(clip.getRootNode())) {
|
|
1091
|
+
const style = getComputedStyle(clip);
|
|
1092
|
+
if (!/(hidden|clip|scroll|auto)/.test(style.overflow + style.overflowX + style.overflowY)) continue;
|
|
1093
|
+
const c = clip.getBoundingClientRect();
|
|
1094
|
+
left = Math.max(left, c.x + clip.clientLeft);
|
|
1095
|
+
top = Math.max(top, c.y + clip.clientTop);
|
|
1096
|
+
right = Math.min(right, c.x + clip.clientLeft + clip.clientWidth);
|
|
1097
|
+
bottom = Math.min(bottom, c.y + clip.clientTop + clip.clientHeight);
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
surface: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
1101
|
+
viewport: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
|
1102
|
+
offset: { x: scroller.scrollLeft, y: scroller.scrollTop },
|
|
1103
|
+
occlusions: [document.getElementById('farmslot-recipe-hud')].map(box).filter(Boolean),
|
|
1104
|
+
...measured,
|
|
1105
|
+
};
|
|
1106
|
+
})()`;
|
|
1107
|
+
}
|
|
1108
|
+
async function captureCdpScreenshot(page, node, context, fullPage = false) {
|
|
1109
|
+
const action = fullPage ? 'ui.capture_surface' : 'ui.screenshot';
|
|
1110
|
+
const artifactPath = asOptionalString(node.path, `${action}.path`) ?? `${context.nodeId}.png`;
|
|
1111
|
+
const normalizedPath = artifactPath.split(path.sep).join('/');
|
|
1112
|
+
const label = asOptionalString(node.label, `${action}.label`) ??
|
|
1113
|
+
(fullPage ? 'Full UI surface' : 'UI screenshot');
|
|
1114
|
+
const captured = await page.screenshot(context, normalizedPath, {
|
|
1115
|
+
label,
|
|
1116
|
+
category: asOptionalString(node.category, `${action}.category`) ?? 'evidence',
|
|
1117
|
+
fullPage,
|
|
1118
|
+
timeoutMs: node.timeout_ms == null ? undefined : asNumber(node.timeout_ms, `${action}.timeout_ms`),
|
|
1119
|
+
});
|
|
1120
|
+
const artifact = typeof captured === 'object' && captured !== null
|
|
1121
|
+
? captured
|
|
1122
|
+
: {
|
|
1123
|
+
path: normalizedPath,
|
|
1124
|
+
type: 'screenshot',
|
|
1125
|
+
nodeId: context.nodeId,
|
|
1126
|
+
mimeType: 'image/png',
|
|
1127
|
+
label,
|
|
1128
|
+
};
|
|
1129
|
+
context.registerArtifact(artifact);
|
|
1130
|
+
return {
|
|
1131
|
+
kind: 'ui-transport-result',
|
|
1132
|
+
output: { captured: true, path: normalizedPath, artifact },
|
|
1133
|
+
control: { artifacts: [artifact] },
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async function runCdpLifecycle(page, node) {
|
|
1137
|
+
const command = asString(node.command ?? node.event, 'app.lifecycle.command');
|
|
1138
|
+
if (command === 'reload') {
|
|
1139
|
+
return page.evaluate('(() => { location.reload(); return { command: "reload" }; })()');
|
|
1140
|
+
}
|
|
1141
|
+
if (command === 'back') {
|
|
1142
|
+
return page.evaluate('(() => { history.back(); return { command: "back" }; })()');
|
|
1143
|
+
}
|
|
1144
|
+
throw new Error(`Unsupported CDP lifecycle command: ${command}.`);
|
|
1145
|
+
}
|
|
1146
|
+
async function renderCdpHud(page, node, context) {
|
|
1147
|
+
if (node.clear === true) {
|
|
1148
|
+
return page.evaluate("(() => { document.getElementById('farmslot-recipe-hud')?.remove(); document.getElementById('farmslot-recipe-hud-reserved-space')?.remove(); document.documentElement.style.removeProperty('--farmslot-recipe-hud-height'); document.body?.style.removeProperty('padding-bottom'); return { hud: false, cleared: true }; })()");
|
|
1149
|
+
}
|
|
1150
|
+
const title = asOptionalString(node.title, 'app.hud.title') ?? 'Recipe run';
|
|
1151
|
+
const status = asOptionalString(node.status, 'app.hud.status') ?? 'running';
|
|
1152
|
+
const nodeId = asOptionalString(node.node_id ?? node.nodeId, 'app.hud.node_id') ?? context.nodeId;
|
|
1153
|
+
const phase = asOptionalString(node.phase, 'app.hud.phase') ?? '';
|
|
1154
|
+
const flow = asOptionalString(node.flow, 'app.hud.flow') ?? '';
|
|
1155
|
+
const action = asOptionalString(node.action_name ?? node.recipe_action, 'app.hud.action_name') ?? '';
|
|
1156
|
+
const text = asOptionalString(node.intent ?? node.text, 'app.hud.text') ?? context.nodeId;
|
|
1157
|
+
const rawDetail = asOptionalString(node.detail, 'app.hud.detail') ?? '';
|
|
1158
|
+
const detail = rawDetail && rawDetail !== text && rawDetail !== flow ? rawDetail : '';
|
|
1159
|
+
const error = asOptionalString(node.error, 'app.hud.error');
|
|
1160
|
+
const display = isRecord(node.display) ? node.display : {};
|
|
1161
|
+
const layout = asOptionalString(display.layout, 'app.hud.display.layout') ?? 'bottom-bar';
|
|
1162
|
+
const position = asOptionalString(display.position, 'app.hud.display.position') ?? 'bottom';
|
|
1163
|
+
const showTitle = display.showTitle === true;
|
|
1164
|
+
const showDebug = display.showDebug === true;
|
|
1165
|
+
const showDetail = display.showDetail === true;
|
|
1166
|
+
const width = asOptionalString(display.width, 'app.hud.display.width') ?? '360px';
|
|
1167
|
+
const maxDetailLines = typeof display.maxDetailLines === 'number' ? display.maxDetailLines : 2;
|
|
1168
|
+
const progress = isRecord(node.progress) ? node.progress : {};
|
|
1169
|
+
const current = typeof progress.current === 'number' ? progress.current : undefined;
|
|
1170
|
+
const total = typeof progress.total === 'number' ? progress.total : undefined;
|
|
1171
|
+
return page.evaluate(`(() => {
|
|
1172
|
+
const payload = ${JSON.stringify({ title, status, nodeId, phase, flow, action, text, detail, error, current, total, layout, position, showTitle, showDebug, showDetail, width, maxDetailLines })};
|
|
1173
|
+
const id = 'farmslot-recipe-hud';
|
|
1174
|
+
let el = document.getElementById(id);
|
|
1175
|
+
if (!el) {
|
|
1176
|
+
el = document.createElement('div');
|
|
1177
|
+
el.id = id;
|
|
1178
|
+
Object.assign(el.style, {
|
|
1179
|
+
position: 'fixed',
|
|
1180
|
+
zIndex: '2147483647',
|
|
1181
|
+
left: '8px',
|
|
1182
|
+
right: '8px',
|
|
1183
|
+
bottom: '8px',
|
|
1184
|
+
minHeight: '30px',
|
|
1185
|
+
padding: '5px 8px',
|
|
1186
|
+
borderRadius: '8px',
|
|
1187
|
+
background: 'rgba(8, 10, 14, 0.66)',
|
|
1188
|
+
color: 'white',
|
|
1189
|
+
font: '10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
1190
|
+
pointerEvents: 'none',
|
|
1191
|
+
boxShadow: '0 6px 22px rgba(0,0,0,0.30)',
|
|
1192
|
+
border: '1px solid rgba(255,255,255,0.14)',
|
|
1193
|
+
backdropFilter: 'blur(4px)',
|
|
1194
|
+
});
|
|
1195
|
+
document.body.appendChild(el);
|
|
1196
|
+
}
|
|
1197
|
+
const isCard = payload.layout === 'card';
|
|
1198
|
+
const isDocked = payload.layout === 'docked-bottom';
|
|
1199
|
+
Object.assign(el.style, {
|
|
1200
|
+
left: '',
|
|
1201
|
+
right: '',
|
|
1202
|
+
top: '',
|
|
1203
|
+
bottom: '',
|
|
1204
|
+
width: '',
|
|
1205
|
+
maxWidth: '',
|
|
1206
|
+
borderRadius: isDocked ? '0' : '9px',
|
|
1207
|
+
});
|
|
1208
|
+
if (isCard) {
|
|
1209
|
+
el.style.width = payload.width || '360px';
|
|
1210
|
+
el.style.maxWidth = 'calc(100vw - 16px)';
|
|
1211
|
+
if (payload.position.includes('top')) el.style.top = '8px';
|
|
1212
|
+
else el.style.bottom = '8px';
|
|
1213
|
+
if (payload.position.includes('left')) el.style.left = '8px';
|
|
1214
|
+
else el.style.right = '8px';
|
|
1215
|
+
} else {
|
|
1216
|
+
if (payload.position === 'top') el.style.top = '8px';
|
|
1217
|
+
else el.style.bottom = isDocked ? '0' : '8px';
|
|
1218
|
+
el.style.left = isDocked ? '0' : '8px';
|
|
1219
|
+
el.style.right = isDocked ? '0' : '8px';
|
|
1220
|
+
}
|
|
1221
|
+
const accent = payload.status === 'fail' ? '#ff5c5c' : payload.status === 'pass' ? '#48d17a' : '#8ab4ff';
|
|
1222
|
+
const progressText = Number.isFinite(payload.current) && Number.isFinite(payload.total)
|
|
1223
|
+
? payload.current + '/' + payload.total
|
|
1224
|
+
: '';
|
|
1225
|
+
el.innerHTML = '';
|
|
1226
|
+
const row = document.createElement('div');
|
|
1227
|
+
Object.assign(row.style, { display: 'flex', gap: '7px', alignItems: 'flex-start' });
|
|
1228
|
+
const badge = document.createElement('div');
|
|
1229
|
+
Object.assign(badge.style, {
|
|
1230
|
+
color: accent,
|
|
1231
|
+
border: '1px solid rgba(255,255,255,0.16)',
|
|
1232
|
+
borderRadius: '7px',
|
|
1233
|
+
padding: '1px 5px',
|
|
1234
|
+
fontWeight: '800',
|
|
1235
|
+
textTransform: 'uppercase',
|
|
1236
|
+
fontSize: '9px',
|
|
1237
|
+
lineHeight: '14px',
|
|
1238
|
+
whiteSpace: 'nowrap',
|
|
1239
|
+
});
|
|
1240
|
+
badge.textContent = [payload.status === 'fail' ? 'FAIL' : payload.status === 'pass' ? 'OK' : 'RUN', progressText].filter(Boolean).join(' ');
|
|
1241
|
+
const body = document.createElement('div');
|
|
1242
|
+
Object.assign(body.style, { minWidth: '0', flex: '1' });
|
|
1243
|
+
const line1 = document.createElement('div');
|
|
1244
|
+
Object.assign(line1.style, { color: '#fff', fontWeight: '750', lineHeight: '1.25', whiteSpace: 'normal' });
|
|
1245
|
+
line1.textContent = payload.text || payload.nodeId;
|
|
1246
|
+
const line2 = document.createElement('div');
|
|
1247
|
+
Object.assign(line2.style, {
|
|
1248
|
+
color: '#d6d9df',
|
|
1249
|
+
lineHeight: '1.25',
|
|
1250
|
+
whiteSpace: 'normal',
|
|
1251
|
+
});
|
|
1252
|
+
const secondaryParts = [];
|
|
1253
|
+
if (payload.showDetail && payload.detail) secondaryParts.push(payload.detail);
|
|
1254
|
+
const secondary = secondaryParts.filter((part, index) => part && secondaryParts.indexOf(part) === index).join(' · ');
|
|
1255
|
+
line2.textContent = payload.error ? 'error: ' + payload.error : secondary;
|
|
1256
|
+
const line3 = document.createElement('div');
|
|
1257
|
+
Object.assign(line3.style, { color: '#8f98a8', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '9px', whiteSpace: 'normal' });
|
|
1258
|
+
line3.textContent = [payload.nodeId, payload.action].filter(Boolean).join(' · ');
|
|
1259
|
+
if (payload.showTitle) {
|
|
1260
|
+
const title = document.createElement('div');
|
|
1261
|
+
Object.assign(title.style, { color: '#d6d9df', fontWeight: '700', marginBottom: '2px', fontSize: '10px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
|
|
1262
|
+
title.textContent = payload.title;
|
|
1263
|
+
body.append(title);
|
|
1264
|
+
}
|
|
1265
|
+
body.append(line1);
|
|
1266
|
+
if (line2.textContent) body.append(line2);
|
|
1267
|
+
if (payload.showDebug) body.append(line3);
|
|
1268
|
+
row.append(badge, body);
|
|
1269
|
+
el.append(row);
|
|
1270
|
+
if (payload.error) {
|
|
1271
|
+
line2.style.color = '#ffb3b3';
|
|
1272
|
+
}
|
|
1273
|
+
let reserve = document.getElementById('farmslot-recipe-hud-reserved-space');
|
|
1274
|
+
if (isDocked) {
|
|
1275
|
+
if (!reserve) {
|
|
1276
|
+
reserve = document.createElement('div');
|
|
1277
|
+
reserve.id = 'farmslot-recipe-hud-reserved-space';
|
|
1278
|
+
reserve.setAttribute('aria-hidden', 'true');
|
|
1279
|
+
document.body.appendChild(reserve);
|
|
1280
|
+
}
|
|
1281
|
+
const height = Math.ceil(el.getBoundingClientRect().height);
|
|
1282
|
+
document.documentElement.style.setProperty('--farmslot-recipe-hud-height', height + 'px');
|
|
1283
|
+
Object.assign(reserve.style, {
|
|
1284
|
+
display: 'block',
|
|
1285
|
+
height: 'var(--farmslot-recipe-hud-height)',
|
|
1286
|
+
minHeight: 'var(--farmslot-recipe-hud-height)',
|
|
1287
|
+
pointerEvents: 'none',
|
|
1288
|
+
});
|
|
1289
|
+
if (document.body) {
|
|
1290
|
+
document.body.style.paddingBottom = 'var(--farmslot-recipe-hud-height)';
|
|
1291
|
+
}
|
|
1292
|
+
} else if (reserve) {
|
|
1293
|
+
reserve.remove();
|
|
1294
|
+
document.documentElement.style.removeProperty('--farmslot-recipe-hud-height');
|
|
1295
|
+
document.body?.style.removeProperty('padding-bottom');
|
|
1296
|
+
}
|
|
1297
|
+
return { hud: true, status: payload.status, nodeId: payload.nodeId, flow: payload.flow };
|
|
1298
|
+
})()`);
|
|
1299
|
+
}
|
|
1300
|
+
function waitForPredicateExpression(options) {
|
|
1301
|
+
let presentExpression;
|
|
1302
|
+
let visibleExpression;
|
|
1303
|
+
if (options.selector && options.text) {
|
|
1304
|
+
const selector = JSON.stringify(options.selector);
|
|
1305
|
+
const text = JSON.stringify(options.text);
|
|
1306
|
+
presentExpression = `Boolean(querySelectorDeep(${selector})) && textIncludesDeep(${text})`;
|
|
1307
|
+
visibleExpression = `${visibleSelectorExpression(options.selector)} && textIncludesDeep(${text})`;
|
|
1308
|
+
}
|
|
1309
|
+
else if (options.selector) {
|
|
1310
|
+
presentExpression = `Boolean(querySelectorDeep(${JSON.stringify(options.selector)}))`;
|
|
1311
|
+
visibleExpression = visibleSelectorExpression(options.selector);
|
|
1312
|
+
}
|
|
1313
|
+
else if (options.text) {
|
|
1314
|
+
presentExpression = `textIncludesDeep(${JSON.stringify(options.text)})`;
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
throw new Error('ui.wait_for requires selector or text.');
|
|
1318
|
+
}
|
|
1319
|
+
const expected = String(options.expected ?? 'present').toLowerCase();
|
|
1320
|
+
if (expected === 'absent' || expected === 'not_present') {
|
|
1321
|
+
return `!(${presentExpression})`;
|
|
1322
|
+
}
|
|
1323
|
+
if (expected === 'hidden') {
|
|
1324
|
+
return `!(${visibleExpression ?? presentExpression})`;
|
|
1325
|
+
}
|
|
1326
|
+
if (expected === 'visible') {
|
|
1327
|
+
return visibleExpression ?? presentExpression;
|
|
1328
|
+
}
|
|
1329
|
+
if (expected !== 'present') {
|
|
1330
|
+
throw new Error(`ui.wait_for expected must be present or absent, got ${expected}.`);
|
|
1331
|
+
}
|
|
1332
|
+
return presentExpression;
|
|
1333
|
+
}
|
|
1334
|
+
function visibleSelectorExpression(selector) {
|
|
1335
|
+
return `(() => { const el = querySelectorDeep(${JSON.stringify(selector)}); return Boolean(el && isVisibleDeep(el)); })()`;
|
|
1336
|
+
}
|
|
1337
|
+
function deepQueryHelpersExpression() {
|
|
1338
|
+
return `
|
|
1339
|
+
const shadowHostFor = (root) =>
|
|
1340
|
+
root && root.nodeType === 11 && root.host ? root.host : null;
|
|
1341
|
+
const querySelectorAllDeep = (selector, root = document) => {
|
|
1342
|
+
const matches = Array.from(root.querySelectorAll(selector));
|
|
1343
|
+
for (const element of root.querySelectorAll('*')) {
|
|
1344
|
+
if (element.shadowRoot) matches.push(...querySelectorAllDeep(selector, element.shadowRoot));
|
|
1345
|
+
}
|
|
1346
|
+
return matches;
|
|
1347
|
+
};
|
|
1348
|
+
const querySelectorDeep = (selector, root = document) => querySelectorAllDeep(selector, root)[0] ?? null;
|
|
1349
|
+
const isRenderedDeep = (element) => {
|
|
1350
|
+
if (!element || element.getClientRects().length === 0) return false;
|
|
1351
|
+
let current = element;
|
|
1352
|
+
while (current) {
|
|
1353
|
+
const style = getComputedStyle(current);
|
|
1354
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || 1) <= 0) return false;
|
|
1355
|
+
const root = current.getRootNode();
|
|
1356
|
+
current = current.parentElement || shadowHostFor(root);
|
|
1357
|
+
}
|
|
1358
|
+
return true;
|
|
1359
|
+
};
|
|
1360
|
+
const isVisibleDeep = (element) => {
|
|
1361
|
+
if (!isRenderedDeep(element)) return false;
|
|
1362
|
+
let rect = element.getBoundingClientRect();
|
|
1363
|
+
let current = element;
|
|
1364
|
+
while (current) {
|
|
1365
|
+
const root = current.getRootNode();
|
|
1366
|
+
const parent = current.parentElement || shadowHostFor(root);
|
|
1367
|
+
if (!parent) break;
|
|
1368
|
+
const style = getComputedStyle(parent);
|
|
1369
|
+
if (/(hidden|clip|scroll|auto)/.test(style.overflow + style.overflowX + style.overflowY)) {
|
|
1370
|
+
const parentRect = parent.getBoundingClientRect();
|
|
1371
|
+
rect = { left: Math.max(rect.left, parentRect.left), top: Math.max(rect.top, parentRect.top), right: Math.min(rect.right, parentRect.right), bottom: Math.min(rect.bottom, parentRect.bottom) };
|
|
1372
|
+
if (rect.right <= rect.left || rect.bottom <= rect.top) return false;
|
|
1373
|
+
}
|
|
1374
|
+
current = parent;
|
|
1375
|
+
}
|
|
1376
|
+
return rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight;
|
|
1377
|
+
};
|
|
1378
|
+
const clickablePointDeep = (element, observeObstruction = false) => {
|
|
1379
|
+
if (!isVisibleDeep(element)) throw new Error('Target is not visible');
|
|
1380
|
+
if (element.disabled || element.getAttribute('aria-disabled') === 'true') throw new Error('Target is disabled');
|
|
1381
|
+
const rect = element.getBoundingClientRect();
|
|
1382
|
+
const x = Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2));
|
|
1383
|
+
const y = Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2));
|
|
1384
|
+
let hit = document.elementFromPoint(x, y);
|
|
1385
|
+
while (hit && hit.shadowRoot) {
|
|
1386
|
+
const deeper = hit.shadowRoot.elementFromPoint(x, y);
|
|
1387
|
+
if (!deeper || deeper === hit) break;
|
|
1388
|
+
hit = deeper;
|
|
1389
|
+
}
|
|
1390
|
+
const composedContains = (ancestor, descendant) => {
|
|
1391
|
+
let current = descendant;
|
|
1392
|
+
while (current) {
|
|
1393
|
+
if (current === ancestor) return true;
|
|
1394
|
+
const root = current.getRootNode();
|
|
1395
|
+
current = current.parentElement || shadowHostFor(root);
|
|
1396
|
+
}
|
|
1397
|
+
return false;
|
|
1398
|
+
};
|
|
1399
|
+
if (!hit || !composedContains(element, hit)) {
|
|
1400
|
+
if (observeObstruction) return null;
|
|
1401
|
+
throw new Error('Target is obscured');
|
|
1402
|
+
}
|
|
1403
|
+
return { x, y };
|
|
1404
|
+
};
|
|
1405
|
+
const renderedTextDeep = (root = document) => {
|
|
1406
|
+
const chunks = [];
|
|
1407
|
+
const walker = document.createTreeWalker(root, 4);
|
|
1408
|
+
let node = walker.nextNode();
|
|
1409
|
+
while (node) {
|
|
1410
|
+
if (node.parentElement && isRenderedDeep(node.parentElement)) chunks.push(node.nodeValue || '');
|
|
1411
|
+
node = walker.nextNode();
|
|
1412
|
+
}
|
|
1413
|
+
for (const element of root.querySelectorAll('*')) {
|
|
1414
|
+
if (element.shadowRoot && isRenderedDeep(element)) {
|
|
1415
|
+
chunks.push(renderedTextDeep(element.shadowRoot));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return chunks.join('\\n');
|
|
1419
|
+
};
|
|
1420
|
+
const textIncludesDeep = (text) => renderedTextDeep().includes(text);
|
|
1421
|
+
`;
|
|
1422
|
+
}
|
|
1423
|
+
function escapeForJsMessage(value) {
|
|
1424
|
+
return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
|
|
1425
|
+
}
|
|
1426
|
+
function escapeCssAttrValue(value) {
|
|
1427
|
+
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
1428
|
+
}
|
|
1429
|
+
//# sourceMappingURL=cdp.js.map
|