flowstride 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/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/adapters/api/undici.d.ts +46 -0
- package/dist/adapters/api/undici.js +118 -0
- package/dist/adapters/web/playwright.d.ts +51 -0
- package/dist/adapters/web/playwright.js +1108 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +77 -0
- package/dist/cli/commands/report.d.ts +1 -0
- package/dist/cli/commands/report.js +78 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +158 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +39 -0
- package/dist/parser/lexer.d.ts +29 -0
- package/dist/parser/lexer.js +425 -0
- package/dist/parser/parser.d.ts +41 -0
- package/dist/parser/parser.js +573 -0
- package/dist/parser/resolver.d.ts +5 -0
- package/dist/parser/resolver.js +19 -0
- package/dist/parser/validator.d.ts +4 -0
- package/dist/parser/validator.js +37 -0
- package/dist/parser/variable_resolver.d.ts +7 -0
- package/dist/parser/variable_resolver.js +33 -0
- package/dist/runner/data_generators.d.ts +55 -0
- package/dist/runner/data_generators.js +233 -0
- package/dist/runner/dom_stripper.d.ts +4 -0
- package/dist/runner/dom_stripper.js +61 -0
- package/dist/runner/events.d.ts +15 -0
- package/dist/runner/events.js +20 -0
- package/dist/runner/flow_orchestrator.d.ts +15 -0
- package/dist/runner/flow_orchestrator.js +216 -0
- package/dist/runner/flow_worker.d.ts +53 -0
- package/dist/runner/flow_worker.js +1112 -0
- package/dist/runner/persistent_vault.d.ts +12 -0
- package/dist/runner/persistent_vault.js +84 -0
- package/dist/runner/plugin_manager.d.ts +13 -0
- package/dist/runner/plugin_manager.js +164 -0
- package/dist/runner/temporary_store.d.ts +8 -0
- package/dist/runner/temporary_store.js +22 -0
- package/dist/runner/worker_entry.d.ts +1 -0
- package/dist/runner/worker_entry.js +63 -0
- package/dist/sdk/api_proxy.d.ts +8 -0
- package/dist/sdk/api_proxy.js +28 -0
- package/dist/sdk/db_proxy.d.ts +3 -0
- package/dist/sdk/db_proxy.js +15 -0
- package/dist/sessions/manager.d.ts +17 -0
- package/dist/sessions/manager.js +74 -0
- package/dist/sessions/types.d.ts +39 -0
- package/dist/sessions/types.js +2 -0
- package/dist/sessions/vault.d.ts +8 -0
- package/dist/sessions/vault.js +69 -0
- package/dist/test_data/commerce.json +341 -0
- package/dist/test_data/geography.json +1298 -0
- package/dist/test_data/identities.json +1420 -0
- package/dist/test_data/phone_data.json +27 -0
- package/dist/test_data/utility.json +246 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/redactor.d.ts +5 -0
- package/dist/utils/redactor.js +53 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.WebAdapter = void 0;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const playwright_extra_1 = require("playwright-extra");
|
|
43
|
+
const puppeteer_extra_plugin_stealth_1 = __importDefault(require("puppeteer-extra-plugin-stealth"));
|
|
44
|
+
playwright_extra_1.chromium.use((0, puppeteer_extra_plugin_stealth_1.default)());
|
|
45
|
+
class WebAdapter {
|
|
46
|
+
config;
|
|
47
|
+
browser = null;
|
|
48
|
+
context = null;
|
|
49
|
+
page = null;
|
|
50
|
+
discoveredPages = [];
|
|
51
|
+
dialogHandler = null;
|
|
52
|
+
constructor(config) {
|
|
53
|
+
this.config = config;
|
|
54
|
+
}
|
|
55
|
+
getPage() {
|
|
56
|
+
if (!this.page)
|
|
57
|
+
throw new Error("Browser not initialised");
|
|
58
|
+
return this.page;
|
|
59
|
+
}
|
|
60
|
+
async initialise(audioInjectionPath) {
|
|
61
|
+
const launchArgs = this.config.headless
|
|
62
|
+
? [
|
|
63
|
+
"--use-fake-ui-for-media-stream",
|
|
64
|
+
"--use-fake-device-for-media-stream",
|
|
65
|
+
"--disable-blink-features=AutomationControlled", // Cloudflare Mask
|
|
66
|
+
]
|
|
67
|
+
: [
|
|
68
|
+
"--window-size=1280,800",
|
|
69
|
+
"--use-fake-ui-for-media-stream",
|
|
70
|
+
"--use-fake-device-for-media-stream",
|
|
71
|
+
"--disable-blink-features=AutomationControlled", // Cloudflare Mask
|
|
72
|
+
];
|
|
73
|
+
if (audioInjectionPath) {
|
|
74
|
+
launchArgs.push(`--use-file-for-fake-audio-capture=${audioInjectionPath}`);
|
|
75
|
+
}
|
|
76
|
+
this.browser = await playwright_extra_1.chromium.launch({
|
|
77
|
+
headless: this.config.headless,
|
|
78
|
+
args: launchArgs,
|
|
79
|
+
});
|
|
80
|
+
const videoDir = path.join(process.cwd(), ".flowstride", "videos");
|
|
81
|
+
if (!fs.existsSync(videoDir))
|
|
82
|
+
fs.mkdirSync(videoDir, { recursive: true });
|
|
83
|
+
const customUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
84
|
+
this.context = await this.browser.newContext({
|
|
85
|
+
viewport: null,
|
|
86
|
+
userAgent: customUserAgent,
|
|
87
|
+
recordVideo: {
|
|
88
|
+
dir: videoDir,
|
|
89
|
+
size: { width: 1280, height: 800 },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
this.context.on("dialog", async (dialog) => {
|
|
93
|
+
if (this.dialogHandler) {
|
|
94
|
+
await this.dialogHandler(dialog);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
if (this.config.defaultDialogBehavior === "accept") {
|
|
98
|
+
await dialog.accept().catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await dialog.dismiss().catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
this.context.on("page", (page) => {
|
|
106
|
+
this.discoveredPages.push(page);
|
|
107
|
+
page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
108
|
+
});
|
|
109
|
+
this.page = await this.context.newPage();
|
|
110
|
+
this.page.setDefaultTimeout(this.config.timeout);
|
|
111
|
+
}
|
|
112
|
+
async acceptDialog() {
|
|
113
|
+
this.dialogHandler = async (dialog) => {
|
|
114
|
+
await dialog.accept();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async rejectDialog() {
|
|
118
|
+
this.dialogHandler = async (dialog) => {
|
|
119
|
+
await dialog.dismiss();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async injectAudio(filePath) {
|
|
123
|
+
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
124
|
+
if (!fs.existsSync(absolutePath)) {
|
|
125
|
+
throw new Error(`Audio file not found at: ${absolutePath}`);
|
|
126
|
+
}
|
|
127
|
+
console.log(`Audio feed active, streaming from: ${absolutePath}`);
|
|
128
|
+
}
|
|
129
|
+
async switchTo(target) {
|
|
130
|
+
if (!this.context || !this.page)
|
|
131
|
+
throw new Error("Browser not initialised");
|
|
132
|
+
const targetLower = target.toLowerCase();
|
|
133
|
+
let pages = this.context.pages();
|
|
134
|
+
let currentIndex = pages.indexOf(this.page);
|
|
135
|
+
if (targetLower === "next") {
|
|
136
|
+
const timeout = this.config.timeout || 30000;
|
|
137
|
+
const startTime = Date.now();
|
|
138
|
+
while (Date.now() - startTime < timeout) {
|
|
139
|
+
pages = this.context.pages();
|
|
140
|
+
if (pages.length > currentIndex + 1)
|
|
141
|
+
break;
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
143
|
+
}
|
|
144
|
+
pages = this.context.pages();
|
|
145
|
+
currentIndex = pages.indexOf(this.page);
|
|
146
|
+
if (currentIndex < pages.length - 1) {
|
|
147
|
+
this.page = pages[currentIndex + 1];
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
throw new Error(`No new tab or window was detected after waiting ${timeout / 1000} seconds.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (targetLower === "previous") {
|
|
154
|
+
if (currentIndex > 0) {
|
|
155
|
+
this.page = pages[currentIndex - 1];
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
throw new Error("No previous tab available to switch to.");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
let found = false;
|
|
163
|
+
const cleanTarget = target.replace(/^['"]|['"]$/g, "");
|
|
164
|
+
for (const p of pages) {
|
|
165
|
+
const title = await p.title().catch(() => "");
|
|
166
|
+
const url = p.url();
|
|
167
|
+
if (title.includes(cleanTarget) || url.includes(cleanTarget)) {
|
|
168
|
+
this.page = p;
|
|
169
|
+
found = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!found) {
|
|
174
|
+
throw new Error(`Could not find any open tab matching "${cleanTarget}".`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await this.page.bringToFront();
|
|
178
|
+
await this.page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
async injectStorage(cookies, localStorage, fallbackDomain) {
|
|
181
|
+
if (!this.context || !this.page)
|
|
182
|
+
return;
|
|
183
|
+
if (cookies && cookies.length > 0) {
|
|
184
|
+
const formattedCookies = [];
|
|
185
|
+
for (const cookie of cookies) {
|
|
186
|
+
if (typeof cookie === "string") {
|
|
187
|
+
const parts = cookie.split(";").map((c) => c.trim());
|
|
188
|
+
const nameValue = parts[0];
|
|
189
|
+
const splitIndex = nameValue.indexOf("=");
|
|
190
|
+
if (splitIndex === -1)
|
|
191
|
+
continue;
|
|
192
|
+
const name = nameValue.substring(0, splitIndex);
|
|
193
|
+
const value = nameValue.substring(splitIndex + 1);
|
|
194
|
+
let domain = fallbackDomain ||
|
|
195
|
+
new URL(this.config.baseUrl || "http://localhost").hostname;
|
|
196
|
+
let path = "/";
|
|
197
|
+
for (let i = 1; i < parts.length; i++) {
|
|
198
|
+
const attr = parts[i].toLowerCase();
|
|
199
|
+
if (attr.startsWith("domain="))
|
|
200
|
+
domain = parts[i].substring(7);
|
|
201
|
+
if (attr.startsWith("path="))
|
|
202
|
+
path = parts[i].substring(5);
|
|
203
|
+
}
|
|
204
|
+
domain = domain.replace(/^https?:\/\//, "").split(":")[0];
|
|
205
|
+
formattedCookies.push({ name, value, domain, path });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
formattedCookies.push(cookie);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await this.context.addCookies(formattedCookies);
|
|
212
|
+
}
|
|
213
|
+
if (localStorage && Object.keys(localStorage).length > 0) {
|
|
214
|
+
await this.page.addInitScript((data) => {
|
|
215
|
+
for (const [k, v] of Object.entries(data)) {
|
|
216
|
+
window.localStorage.setItem(k, v);
|
|
217
|
+
}
|
|
218
|
+
}, localStorage);
|
|
219
|
+
if (this.page.url() !== "about:blank") {
|
|
220
|
+
await this.page.evaluate((data) => {
|
|
221
|
+
try {
|
|
222
|
+
for (const [k, v] of Object.entries(data))
|
|
223
|
+
window.localStorage.setItem(k, v);
|
|
224
|
+
}
|
|
225
|
+
catch (e) { }
|
|
226
|
+
}, localStorage);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async getState() {
|
|
231
|
+
const cookies = (await this.context?.cookies()) || [];
|
|
232
|
+
let localStorage = {};
|
|
233
|
+
let sessionStorage = {};
|
|
234
|
+
if (this.page && this.page.url() !== "about:blank") {
|
|
235
|
+
try {
|
|
236
|
+
localStorage = await this.page.evaluate(() => ({
|
|
237
|
+
...window.localStorage,
|
|
238
|
+
}));
|
|
239
|
+
sessionStorage = await this.page.evaluate(() => ({
|
|
240
|
+
...window.sessionStorage,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
console.warn("Warning: Could not access storage. Ensure page is loaded.");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { cookies, localStorage, sessionStorage };
|
|
248
|
+
}
|
|
249
|
+
getCurrentUrl() {
|
|
250
|
+
return this.page ? this.page.url() : "about:blank";
|
|
251
|
+
}
|
|
252
|
+
async navigate(url) {
|
|
253
|
+
if (!this.page)
|
|
254
|
+
throw new Error("Browser page is not initialized.");
|
|
255
|
+
let finalUrl = url;
|
|
256
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
257
|
+
const base = this.config.baseUrl || "http://localhost";
|
|
258
|
+
try {
|
|
259
|
+
finalUrl = new URL(url, base).href;
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
const cleanBase = base.replace(/\/$/, "");
|
|
263
|
+
const cleanUrl = url.startsWith("/") ? url : `/${url}`;
|
|
264
|
+
finalUrl = `${cleanBase}${cleanUrl}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
await this.page.goto(finalUrl, {
|
|
268
|
+
waitUntil: "domcontentloaded",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
synthesizeCurl(method, url, headers, postData) {
|
|
272
|
+
let curl = `curl -X ${method.toUpperCase()} '${url}'`;
|
|
273
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
274
|
+
if (key.startsWith(":"))
|
|
275
|
+
continue;
|
|
276
|
+
const safeValue = value.replace(/'/g, "'\\''");
|
|
277
|
+
curl += ` \\\n -H '${key}: ${safeValue}'`;
|
|
278
|
+
}
|
|
279
|
+
if (postData) {
|
|
280
|
+
const safeData = postData.replace(/'/g, "'\\''");
|
|
281
|
+
curl += ` \\\n -d '${safeData}'`;
|
|
282
|
+
}
|
|
283
|
+
return curl;
|
|
284
|
+
}
|
|
285
|
+
async checkAmbiguity(loc, selector) {
|
|
286
|
+
if (/\[\d+\]/.test(selector))
|
|
287
|
+
return loc.first();
|
|
288
|
+
const count = await loc.count();
|
|
289
|
+
if (count === 0)
|
|
290
|
+
return loc.first();
|
|
291
|
+
if (count === 1)
|
|
292
|
+
return loc.first();
|
|
293
|
+
const uniqueElements = [];
|
|
294
|
+
for (let i = 0; i < count; i++) {
|
|
295
|
+
const el = loc.nth(i);
|
|
296
|
+
const box = await el.boundingBox();
|
|
297
|
+
if (!box || box.width === 0 || box.height === 0)
|
|
298
|
+
continue;
|
|
299
|
+
let isDuplicate = false;
|
|
300
|
+
for (const u of uniqueElements) {
|
|
301
|
+
const isIntersecting = !(box.x > u.box.x + u.box.width ||
|
|
302
|
+
box.x + box.width < u.box.x ||
|
|
303
|
+
box.y > u.box.y + u.box.height ||
|
|
304
|
+
box.y + box.height < u.box.y);
|
|
305
|
+
if (isIntersecting) {
|
|
306
|
+
isDuplicate = true;
|
|
307
|
+
const tagName = await el.evaluate((n) => n.tagName.toLowerCase());
|
|
308
|
+
if (["a", "button", "input", "select"].includes(tagName)) {
|
|
309
|
+
u.index = i;
|
|
310
|
+
u.isActionable = true;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!isDuplicate) {
|
|
316
|
+
const tagName = await el.evaluate((n) => n.tagName.toLowerCase());
|
|
317
|
+
const role = await el.getAttribute("role");
|
|
318
|
+
const isActionable = ["a", "button", "input", "select"].includes(tagName) ||
|
|
319
|
+
["button", "link"].includes(role || "");
|
|
320
|
+
uniqueElements.push({ box, index: i, isActionable });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (uniqueElements.length === 1) {
|
|
324
|
+
return loc.nth(uniqueElements[0].index);
|
|
325
|
+
}
|
|
326
|
+
const actionableElements = uniqueElements.filter((e) => e.isActionable);
|
|
327
|
+
if (actionableElements.length === 1) {
|
|
328
|
+
return loc.nth(actionableElements[0].index);
|
|
329
|
+
}
|
|
330
|
+
throw new Error(`Ambiguity Error: I found ${uniqueElements.length} visually distinct elements matching "${selector}" on the screen. To fix this, please specify an element type (e.g., "link" under "Cars") or use a 0-based index (e.g., "[0]").`);
|
|
331
|
+
}
|
|
332
|
+
async close(targetText) {
|
|
333
|
+
const cleanText = targetText.replace(/^['"]|['"]$/g, "");
|
|
334
|
+
const textLoc = this.page.locator(`:text-is("${cleanText}"), :has-text("${cleanText}")`)
|
|
335
|
+
.filter({ visible: true })
|
|
336
|
+
.last();
|
|
337
|
+
if ((await textLoc.count()) === 0) {
|
|
338
|
+
throw new Error(`Geometric Close Error: Could not find any open modal containing the text "${cleanText}".`);
|
|
339
|
+
}
|
|
340
|
+
const clickCoords = await textLoc.evaluate((node) => {
|
|
341
|
+
let container = node;
|
|
342
|
+
while (container && container !== document.body) {
|
|
343
|
+
const style = window.getComputedStyle(container);
|
|
344
|
+
const isDialog = container.getAttribute("role") === "dialog";
|
|
345
|
+
const isModalClass = container.className &&
|
|
346
|
+
typeof container.className === "string" &&
|
|
347
|
+
container.className.toLowerCase().includes("modal");
|
|
348
|
+
const isFixedPopup = (style.position === "fixed" || style.position === "absolute") &&
|
|
349
|
+
parseInt(style.zIndex || "0") > 10;
|
|
350
|
+
if (isDialog || isModalClass || isFixedPopup) {
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
container = container.parentElement;
|
|
354
|
+
}
|
|
355
|
+
if (!container || container === document.body) {
|
|
356
|
+
container = node.parentElement?.parentElement || node;
|
|
357
|
+
}
|
|
358
|
+
const box = container.getBoundingClientRect();
|
|
359
|
+
const candidates = Array.from(container.querySelectorAll('button, svg, a, [role="button"], img'));
|
|
360
|
+
let bestCandidate = null;
|
|
361
|
+
let minDistance = Infinity;
|
|
362
|
+
const topRightX = box.right;
|
|
363
|
+
const topRightY = box.top;
|
|
364
|
+
candidates.forEach((el) => {
|
|
365
|
+
const elBox = el.getBoundingClientRect();
|
|
366
|
+
if (elBox.width === 0 || elBox.height === 0)
|
|
367
|
+
return;
|
|
368
|
+
const isTopRight = elBox.left >= box.left + box.width * 0.5 &&
|
|
369
|
+
elBox.top <= box.top + box.height * 0.5;
|
|
370
|
+
if (isTopRight) {
|
|
371
|
+
const distance = Math.sqrt(Math.pow(topRightX - elBox.right, 2) +
|
|
372
|
+
Math.pow(topRightY - elBox.top, 2));
|
|
373
|
+
if (distance < minDistance) {
|
|
374
|
+
minDistance = distance;
|
|
375
|
+
bestCandidate = el;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (bestCandidate) {
|
|
380
|
+
const finalBox = bestCandidate.getBoundingClientRect();
|
|
381
|
+
return {
|
|
382
|
+
x: finalBox.left + finalBox.width / 2,
|
|
383
|
+
y: finalBox.top + finalBox.height / 2,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { x: box.right - 20, y: box.top + 20 };
|
|
387
|
+
});
|
|
388
|
+
await this.page.mouse.move(clickCoords.x, clickCoords.y);
|
|
389
|
+
await this.page.mouse.down();
|
|
390
|
+
await this.page.mouse.up();
|
|
391
|
+
await this.page.waitForTimeout(300);
|
|
392
|
+
}
|
|
393
|
+
async click(selector, elementType) {
|
|
394
|
+
const loc = await this.getLocator(selector, elementType, false, false);
|
|
395
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
396
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
397
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
398
|
+
await targetLoc.click();
|
|
399
|
+
}
|
|
400
|
+
async type(selector, value, elementType) {
|
|
401
|
+
const loc = await this.getLocator(selector, elementType, false, false);
|
|
402
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
403
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
404
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
405
|
+
let success = false;
|
|
406
|
+
const isMac = process.platform === "darwin";
|
|
407
|
+
const selectAllModifier = isMac ? "Meta+A" : "Control+A";
|
|
408
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
409
|
+
await targetLoc.click({ clickCount: 3 });
|
|
410
|
+
await targetLoc.press(selectAllModifier);
|
|
411
|
+
await targetLoc.press("Backspace");
|
|
412
|
+
await targetLoc.fill("");
|
|
413
|
+
await targetLoc.pressSequentially(value, { delay: 30 });
|
|
414
|
+
await targetLoc.evaluate((node) => node.blur());
|
|
415
|
+
let actualValue = "";
|
|
416
|
+
try {
|
|
417
|
+
actualValue = await targetLoc.inputValue({ timeout: 500 });
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
actualValue = (await targetLoc.textContent({ timeout: 500 })) || "";
|
|
421
|
+
}
|
|
422
|
+
const cleanActual = actualValue.replace(/[^a-zA-Z0-9]/g, "");
|
|
423
|
+
const cleanExpected = value.replace(/[^a-zA-Z0-9]/g, "");
|
|
424
|
+
if (actualValue === value ||
|
|
425
|
+
cleanActual === cleanExpected ||
|
|
426
|
+
(cleanExpected.length > 0 && cleanActual.includes(cleanExpected))) {
|
|
427
|
+
success = true;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
await this.page.waitForTimeout(200);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (!success) {
|
|
435
|
+
throw new Error(`Framework rejected typing. Expected "${value}" but the field remained empty or mismatched.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async passcode(selector, value, elementType) {
|
|
439
|
+
let loc = await this.getLocator(selector, elementType, true, false);
|
|
440
|
+
let inputs = loc;
|
|
441
|
+
let count = await inputs.count();
|
|
442
|
+
if (count === 1) {
|
|
443
|
+
const tagName = await inputs.evaluate((n) => n.tagName.toLowerCase());
|
|
444
|
+
if (tagName !== "input") {
|
|
445
|
+
inputs = inputs.locator("input");
|
|
446
|
+
count = await inputs.count();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (count === 0) {
|
|
450
|
+
inputs = this.page.locator('input[maxlength="1"], input[inputmode="numeric"], input[autocomplete="one-time-code"]');
|
|
451
|
+
count = await inputs.count();
|
|
452
|
+
if (count > 8) {
|
|
453
|
+
inputs = this.page.locator('form input[maxlength="1"], [role="dialog"] input[maxlength="1"]');
|
|
454
|
+
count = await inputs.count();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (count === 0) {
|
|
458
|
+
throw new Error(`Passcode Error: Could not find any input fields (even hidden ones) matching "${selector}".`);
|
|
459
|
+
}
|
|
460
|
+
if (count === 1) {
|
|
461
|
+
await inputs.fill(value, { force: true });
|
|
462
|
+
await inputs.evaluate((node, val) => {
|
|
463
|
+
node.value = val;
|
|
464
|
+
node.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
465
|
+
node.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
|
466
|
+
}, value);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const chars = value.split("");
|
|
470
|
+
for (let i = 0; i < Math.min(chars.length, count); i++) {
|
|
471
|
+
const inputLoc = inputs.nth(i);
|
|
472
|
+
await inputLoc.scrollIntoViewIfNeeded().catch(() => { });
|
|
473
|
+
await inputLoc
|
|
474
|
+
.evaluate((node) => node.focus())
|
|
475
|
+
.catch(() => { });
|
|
476
|
+
await inputLoc.evaluate((node) => (node.value = ""));
|
|
477
|
+
try {
|
|
478
|
+
await inputLoc.pressSequentially(chars[i], { delay: 30 });
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
await inputLoc.evaluate((node, char) => {
|
|
482
|
+
node.value = char;
|
|
483
|
+
}, chars[i]);
|
|
484
|
+
}
|
|
485
|
+
await inputLoc.evaluate((node) => {
|
|
486
|
+
node.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
487
|
+
node.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
|
488
|
+
});
|
|
489
|
+
await this.page.waitForTimeout(50);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async check(selector, elementType) {
|
|
493
|
+
const loc = await this.getLocator(selector, elementType, false, false);
|
|
494
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
495
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
496
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
497
|
+
try {
|
|
498
|
+
await targetLoc.check({ timeout: 1500 });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error.message.includes("Not a checkbox or radio button")) {
|
|
502
|
+
const isChecked = await targetLoc.evaluate((node) => {
|
|
503
|
+
return (node.getAttribute("aria-checked") === "true" ||
|
|
504
|
+
node.classList.contains("checked") ||
|
|
505
|
+
node.classList.contains("active") ||
|
|
506
|
+
node.classList.contains("selected"));
|
|
507
|
+
});
|
|
508
|
+
if (!isChecked) {
|
|
509
|
+
await targetLoc.click({ force: true });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async uncheck(selector, elementType) {
|
|
518
|
+
const loc = await this.getLocator(selector, elementType, false, false);
|
|
519
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
520
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
521
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
522
|
+
try {
|
|
523
|
+
await targetLoc.uncheck({ timeout: 1500 });
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (error.message.includes("Not a checkbox or radio button")) {
|
|
527
|
+
const isChecked = await targetLoc.evaluate((node) => {
|
|
528
|
+
return (node.getAttribute("aria-checked") === "true" ||
|
|
529
|
+
node.classList.contains("checked") ||
|
|
530
|
+
node.classList.contains("active") ||
|
|
531
|
+
node.classList.contains("selected"));
|
|
532
|
+
});
|
|
533
|
+
if (isChecked) {
|
|
534
|
+
await targetLoc.click({ force: true });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async upload(selector, filePath, elementType) {
|
|
543
|
+
const loc = await this.getLocator(selector, elementType, false, false);
|
|
544
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
545
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
546
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
547
|
+
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
548
|
+
if (!fs.existsSync(absolutePath)) {
|
|
549
|
+
throw new Error(`File Upload Error: Could not find the file at "${absolutePath}". Please ensure the file exists in your project directory.`);
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const fileChooserPromise = this.page.waitForEvent("filechooser", {
|
|
553
|
+
timeout: 5000,
|
|
554
|
+
});
|
|
555
|
+
await targetLoc.click();
|
|
556
|
+
const fileChooser = await fileChooserPromise;
|
|
557
|
+
await fileChooser.setFiles(absolutePath);
|
|
558
|
+
}
|
|
559
|
+
catch (e) {
|
|
560
|
+
try {
|
|
561
|
+
await targetLoc.setInputFiles(absolutePath);
|
|
562
|
+
}
|
|
563
|
+
catch (fallbackError) {
|
|
564
|
+
throw new Error(`Failed to upload file. The element "${selector}" did not trigger a native file dialog, nor is it a direct file input.`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async set(selector, value, elementType) {
|
|
569
|
+
const loc = await this.getLocator(selector, elementType, true, false);
|
|
570
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
571
|
+
const isDateValue = /^\d{4}-\d{2}-\d{2}/.test(value);
|
|
572
|
+
await targetLoc.evaluate((node, val) => {
|
|
573
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
574
|
+
if (nativeInputValueSetter) {
|
|
575
|
+
nativeInputValueSetter.call(node, val);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
node.value = val;
|
|
579
|
+
}
|
|
580
|
+
if (node.setAttribute)
|
|
581
|
+
node.setAttribute("value", val);
|
|
582
|
+
const stateAttributes = [
|
|
583
|
+
"aria-valuenow",
|
|
584
|
+
"data-value",
|
|
585
|
+
"data-date",
|
|
586
|
+
"data-selected",
|
|
587
|
+
"data-time",
|
|
588
|
+
];
|
|
589
|
+
stateAttributes.forEach((attr) => {
|
|
590
|
+
if (node.hasAttribute(attr)) {
|
|
591
|
+
node.setAttribute(attr, val);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
node.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
595
|
+
node.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
|
596
|
+
node.dispatchEvent(new Event("blur", { bubbles: true, composed: true }));
|
|
597
|
+
}, value);
|
|
598
|
+
await this.page.waitForTimeout(200);
|
|
599
|
+
let finalValue = "";
|
|
600
|
+
try {
|
|
601
|
+
finalValue = await targetLoc.inputValue({ timeout: 200 });
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
finalValue =
|
|
605
|
+
(await targetLoc.getAttribute("value", { timeout: 200 })) || "";
|
|
606
|
+
}
|
|
607
|
+
const targetDatePart = value.split("T")[0];
|
|
608
|
+
if (isDateValue && !finalValue.includes(targetDatePart)) {
|
|
609
|
+
await this.fallbackCalendarSelect(targetLoc, value);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async fallbackCalendarSelect(targetLoc, dateValue) {
|
|
613
|
+
await targetLoc.click({ force: true });
|
|
614
|
+
await this.page.waitForTimeout(500);
|
|
615
|
+
const date = new Date(dateValue);
|
|
616
|
+
if (isNaN(date.getTime()))
|
|
617
|
+
return;
|
|
618
|
+
const month = date.toLocaleString("default", { month: "long" });
|
|
619
|
+
const day = date.getDate();
|
|
620
|
+
const year = date.getFullYear();
|
|
621
|
+
const dateLabelPart = `${month} ${day}`;
|
|
622
|
+
const accessibleCellLocator = this.page.locator(`[role="gridcell"][aria-label*="${dateLabelPart}"][aria-label*="${year}"], ` +
|
|
623
|
+
`[role="button"][aria-label*="${dateLabelPart}"][aria-label*="${year}"]`).filter({ visible: true });
|
|
624
|
+
const rawClassLocator = this.page.locator(`.react-datepicker__day--0${day.toString().padStart(2, "0")}, ` +
|
|
625
|
+
`.day:has-text("${day}")`).filter({ visible: true });
|
|
626
|
+
const targetDay = (await accessibleCellLocator.count()) > 0
|
|
627
|
+
? accessibleCellLocator
|
|
628
|
+
: rawClassLocator;
|
|
629
|
+
if ((await targetDay.count()) > 0) {
|
|
630
|
+
await targetDay.first().click({ force: true });
|
|
631
|
+
await this.page.waitForTimeout(300);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
throw new Error(`Framework rejected direct date injection, and semantic calendar scanner could not find a visible cell for "${month} ${day}, ${year}".`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async select(option, selector, elementType) {
|
|
638
|
+
const rawLoc = await this.getLocator(selector, elementType, false, false);
|
|
639
|
+
const targetLoc = await this.checkAmbiguity(rawLoc, selector);
|
|
640
|
+
await targetLoc.waitFor({ state: "visible" });
|
|
641
|
+
const isReady = await targetLoc.evaluate((n) => {
|
|
642
|
+
const disabled = n.disabled ||
|
|
643
|
+
n.getAttribute("aria-disabled") === "true" ||
|
|
644
|
+
n.classList.contains("disabled");
|
|
645
|
+
return !disabled;
|
|
646
|
+
});
|
|
647
|
+
if (!isReady) {
|
|
648
|
+
throw new Error(`Dropdown "${selector}" is currently disabled.`);
|
|
649
|
+
}
|
|
650
|
+
await targetLoc.scrollIntoViewIfNeeded();
|
|
651
|
+
const tagName = await targetLoc.evaluate((n) => n.tagName.toLowerCase());
|
|
652
|
+
if (tagName === "select") {
|
|
653
|
+
await targetLoc.selectOption({ label: option });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
await targetLoc.click({ force: true });
|
|
657
|
+
await this.page.waitForTimeout(300);
|
|
658
|
+
const activeElement = await this.page.evaluateHandle(() => document.activeElement);
|
|
659
|
+
const activeTagName = await activeElement.evaluate((n) => n?.tagName?.toLowerCase());
|
|
660
|
+
if (activeTagName === "input") {
|
|
661
|
+
await activeElement.asElement()?.fill(option);
|
|
662
|
+
await this.page.waitForTimeout(400);
|
|
663
|
+
}
|
|
664
|
+
const dropdownClasses = '.select__menu, .MuiPopover-root, [role="listbox"], [role="menu"], .dropdown-menu';
|
|
665
|
+
const portalContainer = this.page.locator(dropdownClasses)
|
|
666
|
+
.filter({ visible: true })
|
|
667
|
+
.last();
|
|
668
|
+
const cleanOptionText = option.replace(/^['"]|['"]$/g, "");
|
|
669
|
+
let optionFound = false;
|
|
670
|
+
let optionLoc;
|
|
671
|
+
const optionSelector = `:is([role="option"], .select__option, li):has-text("${cleanOptionText}"), :text-is("${cleanOptionText}")`;
|
|
672
|
+
if ((await portalContainer.count()) > 0) {
|
|
673
|
+
optionLoc = portalContainer.locator(optionSelector).first();
|
|
674
|
+
if ((await optionLoc.count()) === 0) {
|
|
675
|
+
optionLoc = this.page.locator(optionSelector)
|
|
676
|
+
.filter({ visible: true })
|
|
677
|
+
.first();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
optionLoc = this.page.locator(optionSelector)
|
|
682
|
+
.filter({ visible: true })
|
|
683
|
+
.first();
|
|
684
|
+
}
|
|
685
|
+
for (let i = 0; i < 8; i++) {
|
|
686
|
+
if ((await optionLoc.count()) > 0 && (await optionLoc.isVisible())) {
|
|
687
|
+
optionFound = true;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
if ((await portalContainer.count()) > 0) {
|
|
691
|
+
await portalContainer
|
|
692
|
+
.evaluate((node) => node.scrollBy(0, 150))
|
|
693
|
+
.catch(() => { });
|
|
694
|
+
await this.page.waitForTimeout(250);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (!optionFound) {
|
|
701
|
+
throw new Error(`Could not find the option "${option}" in the dropdown "${selector}".`);
|
|
702
|
+
}
|
|
703
|
+
await optionLoc.scrollIntoViewIfNeeded().catch(() => { });
|
|
704
|
+
await optionLoc.click({ force: true });
|
|
705
|
+
await this.page.waitForTimeout(200);
|
|
706
|
+
}
|
|
707
|
+
async drag(sourceSelector, destinationSelector) {
|
|
708
|
+
const sourceLocRaw = await this.getLocator(sourceSelector, undefined, false, false);
|
|
709
|
+
const sourceLoc = await this.checkAmbiguity(sourceLocRaw, sourceSelector);
|
|
710
|
+
const destLocRaw = await this.getLocator(destinationSelector, undefined, false, false);
|
|
711
|
+
const destLoc = await this.checkAmbiguity(destLocRaw, destinationSelector);
|
|
712
|
+
await sourceLoc.waitFor({ state: "visible" });
|
|
713
|
+
await destLoc.waitFor({ state: "visible" });
|
|
714
|
+
await sourceLoc.scrollIntoViewIfNeeded().catch(() => { });
|
|
715
|
+
await destLoc.scrollIntoViewIfNeeded().catch(() => { });
|
|
716
|
+
const srcBox = await sourceLoc.boundingBox();
|
|
717
|
+
const dstBox = await destLoc.boundingBox();
|
|
718
|
+
if (!srcBox || !dstBox)
|
|
719
|
+
return;
|
|
720
|
+
const startX = srcBox.x + srcBox.width / 2;
|
|
721
|
+
const startY = srcBox.y + srcBox.height / 2;
|
|
722
|
+
const endX = dstBox.x + dstBox.width / 2;
|
|
723
|
+
const endY = dstBox.y + dstBox.height / 2;
|
|
724
|
+
try {
|
|
725
|
+
await sourceLoc.dragTo(destLoc, { force: true, timeout: 1000 });
|
|
726
|
+
}
|
|
727
|
+
catch (e) { }
|
|
728
|
+
await this.page.waitForTimeout(300);
|
|
729
|
+
try {
|
|
730
|
+
await this.page.mouse.move(startX, startY);
|
|
731
|
+
await this.page.mouse.down();
|
|
732
|
+
await this.page.waitForTimeout(200);
|
|
733
|
+
await this.page.mouse.move(startX + 10, startY + 10, { steps: 5 });
|
|
734
|
+
await this.page.waitForTimeout(200);
|
|
735
|
+
await this.page.mouse.move(endX, endY, { steps: 15 });
|
|
736
|
+
await this.page.waitForTimeout(200);
|
|
737
|
+
await this.page.mouse.up();
|
|
738
|
+
}
|
|
739
|
+
catch (e) { }
|
|
740
|
+
await this.page.waitForTimeout(300);
|
|
741
|
+
try {
|
|
742
|
+
const srcEl = await sourceLoc.elementHandle();
|
|
743
|
+
const dstEl = await destLoc.elementHandle();
|
|
744
|
+
if (srcEl && dstEl) {
|
|
745
|
+
await this.page.evaluate(([s, d]) => {
|
|
746
|
+
const actualSrc = s.closest('[draggable="true"]') || s;
|
|
747
|
+
const actualDst = d.closest('[draggable="true"]') || d;
|
|
748
|
+
const dataTransfer = new DataTransfer();
|
|
749
|
+
const dataStore = {};
|
|
750
|
+
Object.defineProperty(dataTransfer, "setData", {
|
|
751
|
+
value: (type, val) => {
|
|
752
|
+
dataStore[type] = val;
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
Object.defineProperty(dataTransfer, "getData", {
|
|
756
|
+
value: (type) => dataStore[type],
|
|
757
|
+
});
|
|
758
|
+
Object.defineProperty(dataTransfer, "effectAllowed", {
|
|
759
|
+
value: "all",
|
|
760
|
+
writable: true,
|
|
761
|
+
});
|
|
762
|
+
Object.defineProperty(dataTransfer, "dropEffect", {
|
|
763
|
+
value: "none",
|
|
764
|
+
writable: true,
|
|
765
|
+
});
|
|
766
|
+
const fire = (node, type) => {
|
|
767
|
+
node.dispatchEvent(new DragEvent(type, {
|
|
768
|
+
bubbles: true,
|
|
769
|
+
cancelable: true,
|
|
770
|
+
dataTransfer,
|
|
771
|
+
}));
|
|
772
|
+
};
|
|
773
|
+
fire(actualSrc, "dragstart");
|
|
774
|
+
fire(actualDst, "dragenter");
|
|
775
|
+
fire(actualDst, "dragover");
|
|
776
|
+
fire(actualDst, "drop");
|
|
777
|
+
fire(actualSrc, "dragend");
|
|
778
|
+
}, [srcEl, dstEl]);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch (e) { }
|
|
782
|
+
await this.page.waitForTimeout(1000);
|
|
783
|
+
}
|
|
784
|
+
async forceClick(selector, elementType) {
|
|
785
|
+
const cleanSelector = selector.replace(/^['"]|['"]$/g, "");
|
|
786
|
+
if (cleanSelector.toLowerCase() === "verify you are human" ||
|
|
787
|
+
cleanSelector.toLowerCase() === "cloudflare") {
|
|
788
|
+
const cfWrapper = this.page.locator('iframe[src*="challenges.cloudflare.com"], iframe[title*="Widget containing a Cloudflare security challenge"]').first();
|
|
789
|
+
await cfWrapper
|
|
790
|
+
.waitFor({ state: "attached", timeout: 10000 })
|
|
791
|
+
.catch(() => { });
|
|
792
|
+
await cfWrapper.scrollIntoViewIfNeeded().catch(() => { });
|
|
793
|
+
const box = await cfWrapper.boundingBox();
|
|
794
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
795
|
+
const clickX = box.x + 30;
|
|
796
|
+
const clickY = box.y + box.height / 2;
|
|
797
|
+
await this.page.mouse.move(clickX, clickY, { steps: 10 });
|
|
798
|
+
await this.page.mouse.down();
|
|
799
|
+
await this.page.waitForTimeout(150);
|
|
800
|
+
await this.page.mouse.up();
|
|
801
|
+
await this.page.waitForTimeout(2000);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const loc = await this.getLocator(selector, elementType, true, false);
|
|
806
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
807
|
+
await targetLoc.click({ force: true });
|
|
808
|
+
}
|
|
809
|
+
async forceType(selector, value, elementType) {
|
|
810
|
+
const loc = await this.getLocator(selector, elementType, true, false);
|
|
811
|
+
const targetLoc = await this.checkAmbiguity(loc, selector);
|
|
812
|
+
await targetLoc.fill(value, { force: true });
|
|
813
|
+
}
|
|
814
|
+
async expectVisible(selector, elementType) {
|
|
815
|
+
const loc = await this.getLocator(selector, elementType, false, true);
|
|
816
|
+
await loc.first().waitFor({ state: "visible" });
|
|
817
|
+
}
|
|
818
|
+
async expectText(text, fuzzy = false, elementType) {
|
|
819
|
+
const loc = await this.getLocator(text, elementType, false, true, fuzzy);
|
|
820
|
+
await loc.first().waitFor({ state: "visible" });
|
|
821
|
+
}
|
|
822
|
+
async expectValue(selector, val, elementType) {
|
|
823
|
+
const loc = await this.getLocator(selector, elementType, false, true);
|
|
824
|
+
const actual = await loc.first().inputValue();
|
|
825
|
+
if (actual !== val)
|
|
826
|
+
throw new Error(`Expected ${val}, got ${actual}`);
|
|
827
|
+
}
|
|
828
|
+
async expectTranscript(expectedText) {
|
|
829
|
+
const cleanExpected = expectedText
|
|
830
|
+
.toLowerCase()
|
|
831
|
+
.replace(/[^\w\s]/g, "")
|
|
832
|
+
.replace(/\s+/g, " ")
|
|
833
|
+
.trim();
|
|
834
|
+
const timeout = this.config.timeout || 30000;
|
|
835
|
+
const startTime = Date.now();
|
|
836
|
+
while (Date.now() - startTime < timeout) {
|
|
837
|
+
const bodyText = await this.page.locator("body")
|
|
838
|
+
.innerText()
|
|
839
|
+
.catch(() => "");
|
|
840
|
+
const cleanBody = bodyText
|
|
841
|
+
.toLowerCase()
|
|
842
|
+
.replace(/[^\w\s]/g, "")
|
|
843
|
+
.replace(/\s+/g, " ");
|
|
844
|
+
if (cleanBody.includes(cleanExpected)) {
|
|
845
|
+
return; // Assertion passed!
|
|
846
|
+
}
|
|
847
|
+
await this.page.waitForTimeout(500);
|
|
848
|
+
}
|
|
849
|
+
throw new Error(`Transcript Assertion Failed: Expected to see/hear "${expectedText}", but the phrase never appeared in the active layout.`);
|
|
850
|
+
}
|
|
851
|
+
async getAttribute(selector, attr, elementType) {
|
|
852
|
+
const loc = await this.getLocator(selector, elementType, true, true);
|
|
853
|
+
return await loc.first().getAttribute(attr);
|
|
854
|
+
}
|
|
855
|
+
isRawCssSelector(selector) {
|
|
856
|
+
if (/\b(near|above|under|below|leftOf|rightOf|inside|in)\s+['"]/i.test(selector))
|
|
857
|
+
return false;
|
|
858
|
+
if (/^[#\.\[]/.test(selector))
|
|
859
|
+
return true;
|
|
860
|
+
if (/^[a-zA-Z0-9]+\s*[\.\#:][a-zA-Z0-9_\-]+/.test(selector))
|
|
861
|
+
return true;
|
|
862
|
+
if (/^[a-zA-Z0-9]+\s*\[(?!\d+\])/.test(selector))
|
|
863
|
+
return true;
|
|
864
|
+
if (/^(text|css|xpath)=/.test(selector))
|
|
865
|
+
return true;
|
|
866
|
+
const htmlTags = [
|
|
867
|
+
"button",
|
|
868
|
+
"nav",
|
|
869
|
+
"form",
|
|
870
|
+
"main",
|
|
871
|
+
"header",
|
|
872
|
+
"footer",
|
|
873
|
+
"section",
|
|
874
|
+
"article",
|
|
875
|
+
"aside",
|
|
876
|
+
"dialog",
|
|
877
|
+
"ul",
|
|
878
|
+
"ol",
|
|
879
|
+
"li",
|
|
880
|
+
"div",
|
|
881
|
+
"span",
|
|
882
|
+
"a",
|
|
883
|
+
"input",
|
|
884
|
+
"table",
|
|
885
|
+
"select",
|
|
886
|
+
"textarea",
|
|
887
|
+
"h1",
|
|
888
|
+
"h2",
|
|
889
|
+
"h3",
|
|
890
|
+
"h4",
|
|
891
|
+
"h5",
|
|
892
|
+
"h6",
|
|
893
|
+
"p",
|
|
894
|
+
"iframe",
|
|
895
|
+
"img",
|
|
896
|
+
"svg",
|
|
897
|
+
];
|
|
898
|
+
if (htmlTags.includes(selector.toLowerCase()))
|
|
899
|
+
return true;
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
buildHeuristicSelector(targetText, elementType, allowStaticText = false, fuzzy = false) {
|
|
903
|
+
const cleanText = targetText.replace(/^['"]|['"]$/g, "");
|
|
904
|
+
const textMatch = fuzzy
|
|
905
|
+
? `:text("${cleanText}")`
|
|
906
|
+
: `:text-is("${cleanText}")`;
|
|
907
|
+
const optionMatch = fuzzy
|
|
908
|
+
? `select:has(option:text("${cleanText}"))`
|
|
909
|
+
: `select:has(option:text-is("${cleanText}"))`;
|
|
910
|
+
if (elementType) {
|
|
911
|
+
switch (elementType) {
|
|
912
|
+
case "button":
|
|
913
|
+
return `:is(button:has-text("${cleanText}"), [role="button"]:has-text("${cleanText}"), input[type="submit"][value="${cleanText}" i], input[type="button"][value="${cleanText}" i])`;
|
|
914
|
+
case "link":
|
|
915
|
+
case "a":
|
|
916
|
+
return `:is(a:has-text("${cleanText}"), [role="link"]:has-text("${cleanText}"))`;
|
|
917
|
+
case "input":
|
|
918
|
+
case "field":
|
|
919
|
+
return `:is(input[placeholder="${cleanText}" i], textarea[placeholder="${cleanText}" i], input[aria-label="${cleanText}" i], label:has-text("${cleanText}") input, label:has-text("${cleanText}") textarea)`;
|
|
920
|
+
case "div":
|
|
921
|
+
return `div:has-text("${cleanText}")`;
|
|
922
|
+
case "span":
|
|
923
|
+
return `span:has-text("${cleanText}")`;
|
|
924
|
+
case "image":
|
|
925
|
+
case "img":
|
|
926
|
+
return `img[alt="${cleanText}" i]`;
|
|
927
|
+
case "text":
|
|
928
|
+
return textMatch;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const attributes = `[placeholder="${cleanText}" i], [aria-label="${cleanText}" i], [title="${cleanText}" i], [name="${cleanText}" i], [value="${cleanText}" i], label:has-text("${cleanText}") input, label:has-text("${cleanText}") textarea, label:has-text("${cleanText}") select, ${optionMatch}`;
|
|
932
|
+
const partialActionable = `button:has-text("${cleanText}"), a:has-text("${cleanText}"), label:has-text("${cleanText}"), [role="button"]:has-text("${cleanText}"), [role="link"]:has-text("${cleanText}"), ${textMatch}`;
|
|
933
|
+
if (allowStaticText)
|
|
934
|
+
return `:is(${attributes}, ${textMatch}, ${partialActionable})`;
|
|
935
|
+
return `:is(${attributes}, ${partialActionable})`;
|
|
936
|
+
}
|
|
937
|
+
async getLocator(rawSelector, elementType, isForceAction = false, isAssertionOrAnchor = false, fuzzy = false) {
|
|
938
|
+
if (this.isRawCssSelector(rawSelector.trim())) {
|
|
939
|
+
let loc = this.page.locator(rawSelector);
|
|
940
|
+
if (!isForceAction)
|
|
941
|
+
loc = loc.filter({ visible: true });
|
|
942
|
+
return loc;
|
|
943
|
+
}
|
|
944
|
+
let target = rawSelector;
|
|
945
|
+
let relation = "";
|
|
946
|
+
let anchor = "";
|
|
947
|
+
const spatialRegex = /\b(near|above|under|below|leftOf|rightOf|inside|in)\s+(['"].*?['"])/i;
|
|
948
|
+
const match = rawSelector.match(spatialRegex);
|
|
949
|
+
if (match) {
|
|
950
|
+
target = rawSelector.substring(0, match.index).trim();
|
|
951
|
+
relation = match[1].toLowerCase();
|
|
952
|
+
anchor = match[2];
|
|
953
|
+
}
|
|
954
|
+
let arrayIndex = null;
|
|
955
|
+
const indexMatch = target.match(/\[(\d+)\]/);
|
|
956
|
+
if (indexMatch) {
|
|
957
|
+
arrayIndex = parseInt(indexMatch[1], 10);
|
|
958
|
+
target = target.replace(indexMatch[0], "").trim();
|
|
959
|
+
}
|
|
960
|
+
const targetClean = target.replace(/^['"]|['"]$/g, "");
|
|
961
|
+
const targetIsRaw = this.isRawCssSelector(targetClean);
|
|
962
|
+
let finalSelector = targetIsRaw
|
|
963
|
+
? targetClean
|
|
964
|
+
: this.buildHeuristicSelector(targetClean, elementType, isAssertionOrAnchor, fuzzy);
|
|
965
|
+
let alternativeSelector = "";
|
|
966
|
+
if (relation && anchor) {
|
|
967
|
+
const anchorClean = anchor.replace(/^['"]|['"]$/g, "");
|
|
968
|
+
const anchorIsRaw = this.isRawCssSelector(anchorClean);
|
|
969
|
+
const anchorSelector = anchorIsRaw
|
|
970
|
+
? anchorClean
|
|
971
|
+
: this.buildHeuristicSelector(anchorClean, undefined, true, fuzzy);
|
|
972
|
+
const anchorLoc = this.page.locator(anchorSelector).first();
|
|
973
|
+
try {
|
|
974
|
+
if ((await anchorLoc.count()) > 0) {
|
|
975
|
+
await anchorLoc.scrollIntoViewIfNeeded();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (e) { }
|
|
979
|
+
switch (relation) {
|
|
980
|
+
case "near":
|
|
981
|
+
finalSelector += `:near(${anchorSelector})`;
|
|
982
|
+
break;
|
|
983
|
+
case "above":
|
|
984
|
+
finalSelector += `:above(${anchorSelector})`;
|
|
985
|
+
break;
|
|
986
|
+
case "under":
|
|
987
|
+
case "below":
|
|
988
|
+
finalSelector += `:below(${anchorSelector})`;
|
|
989
|
+
break;
|
|
990
|
+
case "leftof":
|
|
991
|
+
finalSelector += `:left-of(${anchorSelector})`;
|
|
992
|
+
break;
|
|
993
|
+
case "rightof":
|
|
994
|
+
finalSelector += `:right-of(${anchorSelector})`;
|
|
995
|
+
break;
|
|
996
|
+
case "inside":
|
|
997
|
+
case "in":
|
|
998
|
+
alternativeSelector = `${anchorSelector}:has-text("${targetClean}")`;
|
|
999
|
+
finalSelector = `${anchorSelector} >> ${finalSelector}`;
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
let loc = this.page.locator(finalSelector);
|
|
1004
|
+
if (alternativeSelector) {
|
|
1005
|
+
loc = loc.or(this.page.locator(alternativeSelector));
|
|
1006
|
+
}
|
|
1007
|
+
let targetLoc = isForceAction ? loc : loc.filter({ visible: true });
|
|
1008
|
+
if ((await targetLoc.count()) === 0) {
|
|
1009
|
+
await loc
|
|
1010
|
+
.first()
|
|
1011
|
+
.waitFor({ state: "attached", timeout: 3000 })
|
|
1012
|
+
.catch(() => { });
|
|
1013
|
+
targetLoc = isForceAction ? loc : loc.filter({ visible: true });
|
|
1014
|
+
if ((await loc.count()) > 0 && (await targetLoc.count()) === 0) {
|
|
1015
|
+
await loc
|
|
1016
|
+
.first()
|
|
1017
|
+
.scrollIntoViewIfNeeded()
|
|
1018
|
+
.catch(() => { });
|
|
1019
|
+
targetLoc = isForceAction ? loc : loc.filter({ visible: true });
|
|
1020
|
+
}
|
|
1021
|
+
if ((await targetLoc.count()) === 0) {
|
|
1022
|
+
const timeout = this.config.timeout || 30000;
|
|
1023
|
+
const startTime = Date.now();
|
|
1024
|
+
let foundInFrame = false;
|
|
1025
|
+
while (Date.now() - startTime < timeout) {
|
|
1026
|
+
await this.page.evaluate(() => window.scrollBy(0, 400)).catch(() => { });
|
|
1027
|
+
await this.page.waitForTimeout(150);
|
|
1028
|
+
loc = this.page.locator(finalSelector);
|
|
1029
|
+
if (alternativeSelector) {
|
|
1030
|
+
loc = loc.or(this.page.locator(alternativeSelector));
|
|
1031
|
+
}
|
|
1032
|
+
targetLoc = isForceAction ? loc : loc.filter({ visible: true });
|
|
1033
|
+
if ((await targetLoc.count()) > 0) {
|
|
1034
|
+
await targetLoc
|
|
1035
|
+
.first()
|
|
1036
|
+
.scrollIntoViewIfNeeded()
|
|
1037
|
+
.catch(() => { });
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
const frames = this.page.frames();
|
|
1041
|
+
for (const frame of frames) {
|
|
1042
|
+
if (frame === this.page.mainFrame())
|
|
1043
|
+
continue;
|
|
1044
|
+
try {
|
|
1045
|
+
let frameLoc = frame.locator(finalSelector);
|
|
1046
|
+
if (alternativeSelector)
|
|
1047
|
+
frameLoc = frameLoc.or(frame.locator(alternativeSelector));
|
|
1048
|
+
let frameTargetLoc = isForceAction
|
|
1049
|
+
? frameLoc
|
|
1050
|
+
: frameLoc.filter({ visible: true });
|
|
1051
|
+
if ((await frameTargetLoc.count()) > 0) {
|
|
1052
|
+
targetLoc = frameTargetLoc;
|
|
1053
|
+
foundInFrame = true;
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
catch (e) {
|
|
1058
|
+
if (e.message.includes("detached"))
|
|
1059
|
+
continue;
|
|
1060
|
+
throw e;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (foundInFrame)
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (arrayIndex !== null)
|
|
1069
|
+
return targetLoc.nth(arrayIndex);
|
|
1070
|
+
return targetLoc;
|
|
1071
|
+
}
|
|
1072
|
+
async clearHUD() {
|
|
1073
|
+
await this.page?.evaluate(() => {
|
|
1074
|
+
const el = document.getElementById("fs-steps");
|
|
1075
|
+
if (el)
|
|
1076
|
+
el.innerHTML = "";
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
async updateHUD(id, text, status, error) {
|
|
1080
|
+
if (this.config.headless || !this.page)
|
|
1081
|
+
return;
|
|
1082
|
+
await this.page.evaluate(({ id, text, status, error }) => {
|
|
1083
|
+
const h = window.FlowstrideHUD;
|
|
1084
|
+
if (h)
|
|
1085
|
+
h.updateStep(id, text, status, error);
|
|
1086
|
+
}, { id, text, status, error });
|
|
1087
|
+
}
|
|
1088
|
+
async shutdown(videoTargetPath) {
|
|
1089
|
+
try {
|
|
1090
|
+
const video = this.page?.video();
|
|
1091
|
+
if (this.context) {
|
|
1092
|
+
await this.context.close();
|
|
1093
|
+
}
|
|
1094
|
+
if (video && videoTargetPath) {
|
|
1095
|
+
await video.saveAs(videoTargetPath);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
catch (err) {
|
|
1099
|
+
console.warn("[WebAdapter]: Warning during browser shutdown or video transfer.");
|
|
1100
|
+
}
|
|
1101
|
+
finally {
|
|
1102
|
+
if (this.browser) {
|
|
1103
|
+
await this.browser.close().catch(() => { });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
exports.WebAdapter = WebAdapter;
|