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,1112 @@
|
|
|
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.FlowWorker = void 0;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
43
|
+
const ora_1 = __importDefault(require("ora"));
|
|
44
|
+
const events_1 = require("events");
|
|
45
|
+
const lexer_1 = require("../parser/lexer");
|
|
46
|
+
const parser_1 = require("../parser/parser");
|
|
47
|
+
const data_generators_1 = require("./data_generators");
|
|
48
|
+
const dom_stripper_1 = require("./dom_stripper");
|
|
49
|
+
class FlowWorker extends events_1.EventEmitter {
|
|
50
|
+
config;
|
|
51
|
+
web;
|
|
52
|
+
api;
|
|
53
|
+
store;
|
|
54
|
+
sessions;
|
|
55
|
+
pluginManager;
|
|
56
|
+
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
57
|
+
isAborted = false;
|
|
58
|
+
currentStepContext = "Initializing...";
|
|
59
|
+
currentFileName = "System";
|
|
60
|
+
activeAudioPath = undefined;
|
|
61
|
+
currentCdpSession = null;
|
|
62
|
+
dataGen = null;
|
|
63
|
+
localVariables = {};
|
|
64
|
+
localBrowserSessions = {};
|
|
65
|
+
static globalVariables = {};
|
|
66
|
+
static globalBrowserSessions = {};
|
|
67
|
+
lastApiUrl = "";
|
|
68
|
+
constructor(config, web, api, store, sessions, pluginManager) {
|
|
69
|
+
super();
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.web = web;
|
|
72
|
+
this.api = api;
|
|
73
|
+
this.store = store;
|
|
74
|
+
this.sessions = sessions;
|
|
75
|
+
this.pluginManager = pluginManager;
|
|
76
|
+
}
|
|
77
|
+
broadcast(type, payload) {
|
|
78
|
+
this.emit("worker_event", { type, payload });
|
|
79
|
+
}
|
|
80
|
+
emitLog(type, status, summary, details, customId, overrideFileName) {
|
|
81
|
+
const id = customId || Math.random().toString(36).substr(2, 9);
|
|
82
|
+
this.broadcast("LOG_EVENT", {
|
|
83
|
+
id,
|
|
84
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
85
|
+
type,
|
|
86
|
+
status,
|
|
87
|
+
summary,
|
|
88
|
+
stepContext: this.currentStepContext,
|
|
89
|
+
details,
|
|
90
|
+
fileName: overrideFileName || this.currentFileName,
|
|
91
|
+
});
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
translateError(error, action, payload) {
|
|
95
|
+
const msg = error.message || String(error);
|
|
96
|
+
if (msg.includes("API Assertion Failed") ||
|
|
97
|
+
msg.includes("Network Execution Error") ||
|
|
98
|
+
msg.includes("Extraction Failed") ||
|
|
99
|
+
msg.includes("Plugin Execution Failed") ||
|
|
100
|
+
msg.includes("Scoping Error"))
|
|
101
|
+
return msg;
|
|
102
|
+
const target = payload.selector || payload.text || payload.url || "the element";
|
|
103
|
+
if (msg.includes("Timeout"))
|
|
104
|
+
return `I waited for "${target}" to appear, but it never did.`;
|
|
105
|
+
if (msg.includes("intercepted"))
|
|
106
|
+
return `I found "${target}", but another element is blocking it.`;
|
|
107
|
+
if (msg.includes("Framework rejected"))
|
|
108
|
+
return msg;
|
|
109
|
+
return `An error occurred while trying to ${action}: ${msg.split("\n")[0]}`;
|
|
110
|
+
}
|
|
111
|
+
async captureStepContext(payload) {
|
|
112
|
+
const page = this.web.getPage();
|
|
113
|
+
const context = {};
|
|
114
|
+
try {
|
|
115
|
+
const buffer = await page.screenshot({ type: "jpeg", quality: 50 });
|
|
116
|
+
context.screenshot = buffer.toString("base64");
|
|
117
|
+
}
|
|
118
|
+
catch (e) { }
|
|
119
|
+
if (payload && payload.selector) {
|
|
120
|
+
try {
|
|
121
|
+
const box = await page
|
|
122
|
+
.locator(payload.selector)
|
|
123
|
+
.boundingBox({ timeout: 500 });
|
|
124
|
+
if (box) {
|
|
125
|
+
context.boundingBox = box;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (e) { }
|
|
129
|
+
}
|
|
130
|
+
return context;
|
|
131
|
+
}
|
|
132
|
+
resolveTextVariables(text) {
|
|
133
|
+
let resolved = text;
|
|
134
|
+
resolved = resolved.replace(/\{\{env\.([a-zA-Z0-9_]+)\}\}/g, (_, varName) => {
|
|
135
|
+
const val = process.env[varName];
|
|
136
|
+
if (val === undefined) {
|
|
137
|
+
throw new Error(`Environment Error: The variable "{{env.${varName}}}" is undefined. Make sure it is set in your OS or .env file.`);
|
|
138
|
+
}
|
|
139
|
+
return val;
|
|
140
|
+
});
|
|
141
|
+
resolved = resolved.replace(/@global\[(.*?)\]/g, (_, varName) => {
|
|
142
|
+
const val = FlowWorker.globalVariables[varName];
|
|
143
|
+
if (val === undefined)
|
|
144
|
+
throw new Error(`State Error: Global variable "@global[${varName}]" is undefined.`);
|
|
145
|
+
return val;
|
|
146
|
+
});
|
|
147
|
+
resolved = resolved.replace(/@([a-zA-Z0-9_]+)/g, (match, varName) => {
|
|
148
|
+
const val = this.localVariables[varName];
|
|
149
|
+
return val !== undefined ? val : match;
|
|
150
|
+
});
|
|
151
|
+
return resolved;
|
|
152
|
+
}
|
|
153
|
+
resolveTemplateData(obj) {
|
|
154
|
+
if (typeof obj === "string") {
|
|
155
|
+
let text = this.resolveTextVariables(obj);
|
|
156
|
+
if (this.dataGen)
|
|
157
|
+
text = this.dataGen.resolve(text);
|
|
158
|
+
return text;
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(obj))
|
|
161
|
+
return obj.map((item) => this.resolveTemplateData(item));
|
|
162
|
+
if (obj !== null && typeof obj === "object") {
|
|
163
|
+
const resolvedObj = {};
|
|
164
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
165
|
+
resolvedObj[key] = this.resolveTemplateData(value);
|
|
166
|
+
}
|
|
167
|
+
return resolvedObj;
|
|
168
|
+
}
|
|
169
|
+
return obj;
|
|
170
|
+
}
|
|
171
|
+
extractFromJsonPath(obj, path) {
|
|
172
|
+
if (!obj)
|
|
173
|
+
return undefined;
|
|
174
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
175
|
+
let current = obj;
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
if (current === undefined || current === null)
|
|
178
|
+
return undefined;
|
|
179
|
+
current = current[part];
|
|
180
|
+
}
|
|
181
|
+
return current;
|
|
182
|
+
}
|
|
183
|
+
processExtractions(extracts) {
|
|
184
|
+
if (!extracts || extracts.length === 0)
|
|
185
|
+
return;
|
|
186
|
+
const state = this.api.getState();
|
|
187
|
+
for (const ext of extracts) {
|
|
188
|
+
let val;
|
|
189
|
+
if (ext.target === "resbody") {
|
|
190
|
+
val = this.extractFromJsonPath(state.body, ext.jsonPath);
|
|
191
|
+
}
|
|
192
|
+
else if (ext.target === "resheader") {
|
|
193
|
+
val = state.headers?.[ext.jsonPath.toLowerCase()];
|
|
194
|
+
}
|
|
195
|
+
else if (ext.target === "cookie") {
|
|
196
|
+
const cookies = state.headers?.["set-cookie"];
|
|
197
|
+
if (Array.isArray(cookies)) {
|
|
198
|
+
const match = cookies.find((c) => c.startsWith(`${ext.jsonPath}=`));
|
|
199
|
+
if (match)
|
|
200
|
+
val = match.split("=")[1].split(";")[0];
|
|
201
|
+
}
|
|
202
|
+
else if (typeof cookies === "string") {
|
|
203
|
+
const match = cookies
|
|
204
|
+
.split(",")
|
|
205
|
+
.find((c) => c.trim().startsWith(`${ext.jsonPath}=`));
|
|
206
|
+
if (match)
|
|
207
|
+
val = match.split("=")[1].split(";")[0];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (val === undefined) {
|
|
211
|
+
let debugBody = "";
|
|
212
|
+
try {
|
|
213
|
+
debugBody =
|
|
214
|
+
typeof state.body === "object"
|
|
215
|
+
? JSON.stringify(state.body)
|
|
216
|
+
: String(state.body);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
debugBody = "Unparseable Body";
|
|
220
|
+
}
|
|
221
|
+
const shortBody = debugBody.length > 250
|
|
222
|
+
? debugBody.substring(0, 250) + "..."
|
|
223
|
+
: debugBody;
|
|
224
|
+
throw new Error(`Extraction Failed: Could not find "${ext.jsonPath}" in the API ${ext.target}.\nThe API responded with HTTP ${state.status || "Unknown"}\nActual Body Received: ${shortBody}`);
|
|
225
|
+
}
|
|
226
|
+
if (ext.isGlobal)
|
|
227
|
+
FlowWorker.globalVariables[ext.variableName] = val;
|
|
228
|
+
else
|
|
229
|
+
this.localVariables[ext.variableName] = val;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
emitApiTelemetry(method, url, headers, body) {
|
|
233
|
+
const state = this.api.getState();
|
|
234
|
+
const sharedId = Math.random().toString(36).substr(2, 9);
|
|
235
|
+
const baseUrl = this.config.baseUrl || "http://localhost";
|
|
236
|
+
const cleanUrl = url.startsWith("http")
|
|
237
|
+
? url
|
|
238
|
+
: `${baseUrl.replace(/\/$/, "")}${url.startsWith("/") ? url : `/${url}`}`;
|
|
239
|
+
let endpointName = url;
|
|
240
|
+
try {
|
|
241
|
+
const urlObj = new URL(cleanUrl);
|
|
242
|
+
endpointName = urlObj.pathname.split("/").pop() || urlObj.pathname;
|
|
243
|
+
}
|
|
244
|
+
catch { }
|
|
245
|
+
let parsedReqBody = body;
|
|
246
|
+
let rawBodyString = null;
|
|
247
|
+
try {
|
|
248
|
+
if (typeof body === "string") {
|
|
249
|
+
parsedReqBody = JSON.parse(body);
|
|
250
|
+
rawBodyString = body;
|
|
251
|
+
}
|
|
252
|
+
else if (body) {
|
|
253
|
+
rawBodyString = JSON.stringify(body);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
rawBodyString = typeof body === "string" ? body : null;
|
|
258
|
+
}
|
|
259
|
+
const curlString = this.web.synthesizeCurl(method, cleanUrl, headers || {}, rawBodyString);
|
|
260
|
+
this.emitLog("REST", (state.status || 500) < 400 ? "success" : "error", `${method.toUpperCase()} ${endpointName}`, {
|
|
261
|
+
method: method.toUpperCase(),
|
|
262
|
+
url: cleanUrl,
|
|
263
|
+
requestHeaders: headers,
|
|
264
|
+
requestBody: parsedReqBody,
|
|
265
|
+
responseStatus: state.status,
|
|
266
|
+
responseHeaders: state.headers,
|
|
267
|
+
responseBody: state.body,
|
|
268
|
+
curlString: curlString,
|
|
269
|
+
}, sharedId, this.currentFileName);
|
|
270
|
+
this.broadcast("NETWORK_REQ", {
|
|
271
|
+
id: sharedId,
|
|
272
|
+
method: method.toUpperCase(),
|
|
273
|
+
url: cleanUrl,
|
|
274
|
+
name: endpointName,
|
|
275
|
+
status: state.status,
|
|
276
|
+
curlString: curlString,
|
|
277
|
+
time: new Date().toLocaleTimeString([], {
|
|
278
|
+
hour12: false,
|
|
279
|
+
hour: "2-digit",
|
|
280
|
+
minute: "2-digit",
|
|
281
|
+
second: "2-digit",
|
|
282
|
+
}),
|
|
283
|
+
fileName: this.currentFileName,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async handleReplayRequest(payload) {
|
|
287
|
+
try {
|
|
288
|
+
let parsedHeaders = undefined;
|
|
289
|
+
let parsedBody = undefined;
|
|
290
|
+
try {
|
|
291
|
+
if (payload.headers)
|
|
292
|
+
parsedHeaders = JSON.parse(payload.headers);
|
|
293
|
+
}
|
|
294
|
+
catch (e) { }
|
|
295
|
+
try {
|
|
296
|
+
if (payload.body)
|
|
297
|
+
parsedBody = JSON.parse(payload.body);
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
parsedBody = payload.body;
|
|
301
|
+
}
|
|
302
|
+
const fetchOptions = {
|
|
303
|
+
method: payload.method,
|
|
304
|
+
headers: parsedHeaders,
|
|
305
|
+
};
|
|
306
|
+
if (parsedBody && payload.method !== "GET" && payload.method !== "HEAD") {
|
|
307
|
+
fetchOptions.body =
|
|
308
|
+
typeof parsedBody === "string"
|
|
309
|
+
? parsedBody
|
|
310
|
+
: JSON.stringify(parsedBody);
|
|
311
|
+
}
|
|
312
|
+
const response = await fetch(payload.url, fetchOptions);
|
|
313
|
+
const status = response.status;
|
|
314
|
+
const text = await response.text();
|
|
315
|
+
let responseBody;
|
|
316
|
+
try {
|
|
317
|
+
responseBody = JSON.parse(text);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
responseBody = text;
|
|
321
|
+
}
|
|
322
|
+
this.broadcast("REPLAY_RES", {
|
|
323
|
+
status,
|
|
324
|
+
responseBody,
|
|
325
|
+
fileName: this.currentFileName,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
this.broadcast("REPLAY_RES", {
|
|
330
|
+
status: 500,
|
|
331
|
+
responseBody: { error: error.message },
|
|
332
|
+
fileName: this.currentFileName,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
abort() {
|
|
337
|
+
this.isAborted = true;
|
|
338
|
+
}
|
|
339
|
+
async runBatch(filesToProcess) {
|
|
340
|
+
this.config.headless = true;
|
|
341
|
+
this.activeAudioPath = undefined;
|
|
342
|
+
this.isAborted = false;
|
|
343
|
+
for (const filePath of filesToProcess) {
|
|
344
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
345
|
+
const audioMatch = content.match(/(?:flow\.)?injectAudio\s+["']([^"']+)["']/i);
|
|
346
|
+
if (audioMatch && audioMatch[1]) {
|
|
347
|
+
const rawPath = audioMatch[1];
|
|
348
|
+
const absolutePath = path.resolve(process.cwd(), rawPath);
|
|
349
|
+
if (!fs.existsSync(absolutePath)) {
|
|
350
|
+
throw new Error(`Pre-launch initialization failed: Audio file not found at ${absolutePath}`);
|
|
351
|
+
}
|
|
352
|
+
this.activeAudioPath = absolutePath;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const filePath of filesToProcess) {
|
|
357
|
+
if (this.isAborted)
|
|
358
|
+
break;
|
|
359
|
+
const fileStartTime = Date.now();
|
|
360
|
+
try {
|
|
361
|
+
const fileName = path.basename(filePath);
|
|
362
|
+
this.currentFileName = fileName;
|
|
363
|
+
this.localVariables = {};
|
|
364
|
+
this.localBrowserSessions = {};
|
|
365
|
+
this.api.resetState();
|
|
366
|
+
this.dataGen = new data_generators_1.DataGenerators();
|
|
367
|
+
await this.web.initialise(this.activeAudioPath);
|
|
368
|
+
await this.startStreaming();
|
|
369
|
+
await this.setupNetworkInterception();
|
|
370
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
371
|
+
const hasSessionIntent = content.includes("use session") ||
|
|
372
|
+
content.includes("use global session");
|
|
373
|
+
if (!hasSessionIntent) {
|
|
374
|
+
try {
|
|
375
|
+
const page = this.web.getPage();
|
|
376
|
+
if (page) {
|
|
377
|
+
await page
|
|
378
|
+
.context()
|
|
379
|
+
.clearCookies()
|
|
380
|
+
.catch(() => { });
|
|
381
|
+
await page
|
|
382
|
+
.evaluate(() => {
|
|
383
|
+
try {
|
|
384
|
+
localStorage.clear();
|
|
385
|
+
}
|
|
386
|
+
catch (e) { }
|
|
387
|
+
try {
|
|
388
|
+
sessionStorage.clear();
|
|
389
|
+
}
|
|
390
|
+
catch (e) { }
|
|
391
|
+
})
|
|
392
|
+
.catch(() => { });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (e) { }
|
|
396
|
+
}
|
|
397
|
+
if (!this.isSilent)
|
|
398
|
+
console.log(chalk_1.default.blue.bold(`\n[Execution]: Processing file: ${fileName}`));
|
|
399
|
+
this.emitLog("FILE_HEADER", "info", fileName, { filePath });
|
|
400
|
+
const features = new parser_1.Parser(new lexer_1.Lexer(content).tokenise()).parse();
|
|
401
|
+
for (const f of features) {
|
|
402
|
+
if (this.isAborted)
|
|
403
|
+
break;
|
|
404
|
+
if (!this.isSilent)
|
|
405
|
+
console.log(chalk_1.default.magenta.bold(` Feature: ${f.name}`));
|
|
406
|
+
for (const s of f.scenarios) {
|
|
407
|
+
if (this.isAborted)
|
|
408
|
+
break;
|
|
409
|
+
if (!this.isSilent)
|
|
410
|
+
console.log(chalk_1.default.cyan(` Scenario: ${s.name}`));
|
|
411
|
+
this.broadcast("RUN_START", {
|
|
412
|
+
scenarioName: s.name,
|
|
413
|
+
fileName: this.currentFileName,
|
|
414
|
+
});
|
|
415
|
+
await this.runScenario(s, f.declarations);
|
|
416
|
+
await this.web
|
|
417
|
+
.getPage()
|
|
418
|
+
.waitForLoadState("load", { timeout: 4000 })
|
|
419
|
+
.catch(() => { });
|
|
420
|
+
await this.web
|
|
421
|
+
.getPage()
|
|
422
|
+
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
423
|
+
.catch(() => { });
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (this.isAborted)
|
|
428
|
+
break;
|
|
429
|
+
if (!this.isSilent)
|
|
430
|
+
console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
432
|
+
try {
|
|
433
|
+
const baseName = this.currentFileName.replace(".flow", "");
|
|
434
|
+
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
435
|
+
if (!fs.existsSync(mockCloudDir))
|
|
436
|
+
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
437
|
+
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
438
|
+
await this.web.shutdown(targetVideoPath);
|
|
439
|
+
if (!this.isSilent)
|
|
440
|
+
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
441
|
+
}
|
|
442
|
+
catch (err) { }
|
|
443
|
+
this.broadcast("FILE_COMPLETE", {
|
|
444
|
+
fileName: this.currentFileName,
|
|
445
|
+
duration: Date.now() - fileStartTime,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
if (this.isAborted)
|
|
450
|
+
break;
|
|
451
|
+
if (!this.isSilent)
|
|
452
|
+
console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
|
|
453
|
+
try {
|
|
454
|
+
const baseName = this.currentFileName.replace(".flow", "");
|
|
455
|
+
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
456
|
+
if (!fs.existsSync(mockCloudDir))
|
|
457
|
+
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
458
|
+
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
459
|
+
await this.web.shutdown(targetVideoPath);
|
|
460
|
+
if (!this.isSilent)
|
|
461
|
+
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
462
|
+
}
|
|
463
|
+
catch (err) { }
|
|
464
|
+
this.broadcast("FILE_FAILED", {
|
|
465
|
+
fileName: this.currentFileName,
|
|
466
|
+
duration: Date.now() - fileStartTime,
|
|
467
|
+
error: e.message,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (!this.isSilent && !this.isAborted)
|
|
472
|
+
console.log(chalk_1.default.green("[Execution]: All suites completed successfully."));
|
|
473
|
+
}
|
|
474
|
+
async runScenario(scenario, globalDeclarations = []) {
|
|
475
|
+
for (const step of scenario.steps) {
|
|
476
|
+
if (this.isAborted)
|
|
477
|
+
break;
|
|
478
|
+
const blockName = step.blockDescription.split(" ")[0];
|
|
479
|
+
let blockDesc = step.blockDescription.substring(blockName.length).trim();
|
|
480
|
+
blockDesc = this.resolveTextVariables(blockDesc);
|
|
481
|
+
if (this.dataGen)
|
|
482
|
+
blockDesc = this.dataGen.resolve(blockDesc);
|
|
483
|
+
step.payload = this.resolveTemplateData(step.payload);
|
|
484
|
+
this.currentStepContext = `${blockName} ${blockDesc}`;
|
|
485
|
+
const isOpt = step.payload?.optional;
|
|
486
|
+
const mockSpinner = {
|
|
487
|
+
text: "",
|
|
488
|
+
start() {
|
|
489
|
+
return this;
|
|
490
|
+
},
|
|
491
|
+
succeed() {
|
|
492
|
+
return this;
|
|
493
|
+
},
|
|
494
|
+
fail() {
|
|
495
|
+
return this;
|
|
496
|
+
},
|
|
497
|
+
warn() {
|
|
498
|
+
return this;
|
|
499
|
+
},
|
|
500
|
+
render() {
|
|
501
|
+
return this;
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
const spinner = this.isSilent
|
|
505
|
+
? mockSpinner.start()
|
|
506
|
+
: (0, ora_1.default)({
|
|
507
|
+
text: `${chalk_1.default.blue.bold(blockName)} ${chalk_1.default.white(blockDesc)} ${isOpt ? chalk_1.default.gray("(Optional)") : ""}`,
|
|
508
|
+
indent: 6,
|
|
509
|
+
}).start();
|
|
510
|
+
this.emitLog("ACTION", "info", `${blockName}: ${blockDesc}`, {
|
|
511
|
+
command: step.action,
|
|
512
|
+
payload: step.payload,
|
|
513
|
+
}, undefined, this.currentFileName);
|
|
514
|
+
this.broadcast("STEP_ADD", {
|
|
515
|
+
id: step.id,
|
|
516
|
+
type: blockName,
|
|
517
|
+
description: blockDesc,
|
|
518
|
+
command: step.action,
|
|
519
|
+
status: "running",
|
|
520
|
+
fileName: this.currentFileName,
|
|
521
|
+
});
|
|
522
|
+
try {
|
|
523
|
+
await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
|
|
524
|
+
const stepContext = await this.captureStepContext(step.payload);
|
|
525
|
+
spinner.succeed();
|
|
526
|
+
this.broadcast("STEP_UPDATE", {
|
|
527
|
+
id: step.id,
|
|
528
|
+
status: "passed",
|
|
529
|
+
screenshot: stepContext.screenshot,
|
|
530
|
+
fileName: this.currentFileName,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
if (this.isAborted)
|
|
535
|
+
break;
|
|
536
|
+
const stepContext = await this.captureStepContext(step.payload);
|
|
537
|
+
let strippedDom = "";
|
|
538
|
+
try {
|
|
539
|
+
const page = this.web.getPage();
|
|
540
|
+
if (page) {
|
|
541
|
+
strippedDom = await dom_stripper_1.DOMStripper.getStrippedHTML(page);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (domErr) {
|
|
545
|
+
if (!this.isSilent)
|
|
546
|
+
console.warn(chalk_1.default.yellow(`\n[Warning]: Could not extract stripped DOM: ${domErr}`));
|
|
547
|
+
}
|
|
548
|
+
if (isOpt) {
|
|
549
|
+
spinner.warn(chalk_1.default.yellow(`[Skipped]: ${blockDesc}`));
|
|
550
|
+
this.emitLog("ACTION", "info", `Skipped Optional Step: ${blockDesc}`, {
|
|
551
|
+
reason: "Element not present after full timeout.",
|
|
552
|
+
strippedDom,
|
|
553
|
+
}, undefined, this.currentFileName);
|
|
554
|
+
this.broadcast("STEP_UPDATE", {
|
|
555
|
+
id: step.id,
|
|
556
|
+
status: "skipped",
|
|
557
|
+
screenshot: stepContext.screenshot,
|
|
558
|
+
fileName: this.currentFileName,
|
|
559
|
+
});
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const isUIAction = [
|
|
563
|
+
"click",
|
|
564
|
+
"type",
|
|
565
|
+
"forceClick",
|
|
566
|
+
"forceType",
|
|
567
|
+
"check",
|
|
568
|
+
"uncheck",
|
|
569
|
+
"select",
|
|
570
|
+
"drag",
|
|
571
|
+
"set",
|
|
572
|
+
].includes(step.action);
|
|
573
|
+
const errorString = String(e.message || "");
|
|
574
|
+
const isSelectorError = errorString.includes("Timeout") ||
|
|
575
|
+
errorString.includes("Ambiguity") ||
|
|
576
|
+
errorString.includes("intercepted") ||
|
|
577
|
+
errorString.includes("not found") ||
|
|
578
|
+
errorString.includes("failed to find");
|
|
579
|
+
if (isUIAction &&
|
|
580
|
+
isSelectorError &&
|
|
581
|
+
strippedDom &&
|
|
582
|
+
step.payload?.selector) {
|
|
583
|
+
spinner.fail();
|
|
584
|
+
if (!this.isSilent) {
|
|
585
|
+
console.log(chalk_1.default.magenta(`\n > [AI Healing]: Auto-healing broken selectors is a Flowstride Enterprise feature.`));
|
|
586
|
+
console.log(chalk_1.default.magenta(` > Visit flowstride.io to upgrade and let AI fix broken tests automatically.\n`));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
spinner.fail();
|
|
591
|
+
}
|
|
592
|
+
const humanReadableError = this.translateError(e, step.action, step.payload);
|
|
593
|
+
this.emitLog("ERROR", "error", humanReadableError, {
|
|
594
|
+
rawError: e.message,
|
|
595
|
+
command: step.action,
|
|
596
|
+
screenshot: stepContext.screenshot,
|
|
597
|
+
boundingBox: stepContext.boundingBox,
|
|
598
|
+
strippedDom,
|
|
599
|
+
}, undefined, this.currentFileName);
|
|
600
|
+
this.broadcast("STEP_UPDATE", {
|
|
601
|
+
id: step.id,
|
|
602
|
+
status: "failed",
|
|
603
|
+
screenshot: stepContext.screenshot,
|
|
604
|
+
fileName: this.currentFileName,
|
|
605
|
+
});
|
|
606
|
+
throw e;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async waitForPipeline() {
|
|
611
|
+
if (!this.activeAudioPath || !fs.existsSync(this.activeAudioPath)) {
|
|
612
|
+
throw new Error("Wait Error: No active audio file was injected to wait for.");
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
const buffer = Buffer.alloc(44);
|
|
616
|
+
const fileDescriptor = fs.openSync(this.activeAudioPath, "r");
|
|
617
|
+
fs.readSync(fileDescriptor, buffer, 0, 44, 0);
|
|
618
|
+
fs.closeSync(fileDescriptor);
|
|
619
|
+
const byteRate = buffer.readUInt32LE(28);
|
|
620
|
+
const fileSize = fs.statSync(this.activeAudioPath).size;
|
|
621
|
+
const dataSize = fileSize - 44;
|
|
622
|
+
let durationSeconds = dataSize / byteRate;
|
|
623
|
+
if (!durationSeconds ||
|
|
624
|
+
durationSeconds < 0 ||
|
|
625
|
+
!isFinite(durationSeconds)) {
|
|
626
|
+
durationSeconds = 10;
|
|
627
|
+
}
|
|
628
|
+
const waitTimeMs = Math.max(0, Math.ceil(durationSeconds * 1000) - 500);
|
|
629
|
+
if (!this.isSilent)
|
|
630
|
+
console.log(chalk_1.default.gray(`\n > Detected stream duration: ${durationSeconds.toFixed(1)}s.`));
|
|
631
|
+
if (!this.isSilent)
|
|
632
|
+
console.log(chalk_1.default.gray(` > Pausing runner for ${waitTimeMs}ms...`));
|
|
633
|
+
await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
|
|
634
|
+
if (!this.isSilent)
|
|
635
|
+
console.log(chalk_1.default.gray(` > Stream finished. Proceeding immediately to click Stop...`));
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
throw new Error(`Failed to synchronize media pipeline: ${error.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async executeStep(action, payload, localDeclarations = [], globalDeclarations = []) {
|
|
642
|
+
switch (action) {
|
|
643
|
+
case "extract":
|
|
644
|
+
this.processExtractions([
|
|
645
|
+
{
|
|
646
|
+
target: payload.target.toLowerCase(),
|
|
647
|
+
jsonPath: payload.jsonPath,
|
|
648
|
+
variableName: payload.variableName,
|
|
649
|
+
isGlobal: payload.isGlobal,
|
|
650
|
+
},
|
|
651
|
+
]);
|
|
652
|
+
break;
|
|
653
|
+
case "getOtp":
|
|
654
|
+
if (!this.isSilent) {
|
|
655
|
+
console.log(chalk_1.default.magenta(`\n > [Notice]: Automated OTP extraction is a Flowstride Enterprise feature.`));
|
|
656
|
+
console.log(chalk_1.default.magenta(` > Visit flowstride.io to start your 60-day free trial (up to 1000 free runs).`));
|
|
657
|
+
console.log(chalk_1.default.magenta(` > You can cancel at any time before the 61st day.\n`));
|
|
658
|
+
}
|
|
659
|
+
throw new Error("FlowMail Error: Automated OTP extraction requires Flowstride Enterprise. Visit flowstride.io to start your 60-day free trial.");
|
|
660
|
+
case "plugin":
|
|
661
|
+
if (!this.pluginManager) {
|
|
662
|
+
throw new Error(`Framework Error: PluginManager is not instantiated.`);
|
|
663
|
+
}
|
|
664
|
+
const alias = payload.pluginAlias;
|
|
665
|
+
const method = payload.pluginMethod;
|
|
666
|
+
let resolvedPath = localDeclarations.find((d) => d.alias === alias)?.path;
|
|
667
|
+
if (!resolvedPath) {
|
|
668
|
+
resolvedPath = globalDeclarations.find((d) => d.alias === alias)?.path;
|
|
669
|
+
}
|
|
670
|
+
if (!resolvedPath) {
|
|
671
|
+
throw new Error(`Scoping Error: You tried to use the alias "${alias}" without registering it. Please add 'let ${alias} = "path/to/plugin.ts"' at the top of your scenario or feature.`);
|
|
672
|
+
}
|
|
673
|
+
const cleanPath = resolvedPath
|
|
674
|
+
.replace(/^['"]|['"]$/g, "")
|
|
675
|
+
.replace(/\.ts$/, "");
|
|
676
|
+
const executableTarget = `${cleanPath}.${method}`;
|
|
677
|
+
await this.pluginManager.execute(executableTarget, payload.data);
|
|
678
|
+
break;
|
|
679
|
+
case "post":
|
|
680
|
+
case "get":
|
|
681
|
+
case "put":
|
|
682
|
+
case "patch":
|
|
683
|
+
case "delete":
|
|
684
|
+
const verb = action.toLowerCase();
|
|
685
|
+
const baseUrl = this.config.baseUrl || "http://localhost";
|
|
686
|
+
this.lastApiUrl = payload.url.startsWith("http")
|
|
687
|
+
? payload.url
|
|
688
|
+
: `${baseUrl.replace(/\/$/, "")}${payload.url.startsWith("/") ? payload.url : `/${payload.url}`}`;
|
|
689
|
+
if (verb === "get" || verb === "delete") {
|
|
690
|
+
await this.api[verb](payload.url, payload.headers);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
await this.api[verb](payload.url, payload.headers, payload.body);
|
|
694
|
+
}
|
|
695
|
+
this.emitApiTelemetry(verb, payload.url, payload.headers, payload.body);
|
|
696
|
+
this.processExtractions(payload.extract);
|
|
697
|
+
break;
|
|
698
|
+
case "graphql":
|
|
699
|
+
const gqlBaseUrl = this.config.baseUrl || "http://localhost";
|
|
700
|
+
this.lastApiUrl = payload.url.startsWith("http")
|
|
701
|
+
? payload.url
|
|
702
|
+
: `${gqlBaseUrl.replace(/\/$/, "")}${payload.url.startsWith("/") ? payload.url : `/${payload.url}`}`;
|
|
703
|
+
await this.api.graphql(payload.url, payload.headers, payload.body);
|
|
704
|
+
this.emitApiTelemetry("graphql", payload.url, payload.headers, payload.body);
|
|
705
|
+
this.processExtractions(payload.extract);
|
|
706
|
+
break;
|
|
707
|
+
case "autoHeal":
|
|
708
|
+
throw new Error("Error: flow.autoheal is a Flowstride Enterprise feature. Visit flowstride.io to upgrade.");
|
|
709
|
+
case "save":
|
|
710
|
+
if (payload.type === "session") {
|
|
711
|
+
const stateToSave = await this.web.getState();
|
|
712
|
+
let apiCookies = [];
|
|
713
|
+
try {
|
|
714
|
+
const apiState = this.api.getState();
|
|
715
|
+
if (apiState &&
|
|
716
|
+
apiState.headers &&
|
|
717
|
+
apiState.headers["set-cookie"]) {
|
|
718
|
+
const rawCookies = apiState.headers["set-cookie"];
|
|
719
|
+
apiCookies = Array.isArray(rawCookies)
|
|
720
|
+
? rawCookies
|
|
721
|
+
: [rawCookies];
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch (e) { }
|
|
725
|
+
let fallbackDomain = undefined;
|
|
726
|
+
try {
|
|
727
|
+
if (this.lastApiUrl) {
|
|
728
|
+
fallbackDomain = new URL(this.lastApiUrl).hostname;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
catch (e) { }
|
|
732
|
+
const hybridState = {
|
|
733
|
+
cookies: [...(stateToSave.cookies || []), ...apiCookies],
|
|
734
|
+
localStorage: stateToSave.localStorage || {},
|
|
735
|
+
sessionStorage: stateToSave.sessionStorage || {},
|
|
736
|
+
fallbackDomain,
|
|
737
|
+
};
|
|
738
|
+
if (payload.isGlobal) {
|
|
739
|
+
FlowWorker.globalBrowserSessions[payload.name] = hybridState;
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
this.localBrowserSessions[payload.name] = hybridState;
|
|
743
|
+
}
|
|
744
|
+
if (payload.persist) {
|
|
745
|
+
try {
|
|
746
|
+
const persistDir = path.join(process.cwd(), ".flowstride", "sessions");
|
|
747
|
+
if (!fs.existsSync(persistDir)) {
|
|
748
|
+
fs.mkdirSync(persistDir, { recursive: true });
|
|
749
|
+
}
|
|
750
|
+
const persistPath = path.join(persistDir, `${payload.name}.json`);
|
|
751
|
+
fs.writeFileSync(persistPath, JSON.stringify(hybridState, null, 2), "utf-8");
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (!this.isSilent)
|
|
755
|
+
console.error(chalk_1.default.yellow(`\n > [Warning]: Could not persist: ${err.message}`));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
case "use":
|
|
761
|
+
if (payload.type === "session") {
|
|
762
|
+
let loadedState = payload.isGlobal
|
|
763
|
+
? FlowWorker.globalBrowserSessions[payload.name]
|
|
764
|
+
: this.localBrowserSessions[payload.name];
|
|
765
|
+
if (!loadedState) {
|
|
766
|
+
const persistPath = path.join(process.cwd(), ".flowstride", "sessions", `${payload.name}.json`);
|
|
767
|
+
if (fs.existsSync(persistPath)) {
|
|
768
|
+
try {
|
|
769
|
+
const fileData = fs.readFileSync(persistPath, "utf-8");
|
|
770
|
+
loadedState = JSON.parse(fileData);
|
|
771
|
+
if (payload.isGlobal) {
|
|
772
|
+
FlowWorker.globalBrowserSessions[payload.name] = loadedState;
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
this.localBrowserSessions[payload.name] = loadedState;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
throw new Error(`State Error: Failed to parse persisted session file for "${payload.name}".`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (!loadedState)
|
|
784
|
+
throw new Error(`State Error: The session "${payload.name}" does not exist in memory or on disk.`);
|
|
785
|
+
await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain);
|
|
786
|
+
}
|
|
787
|
+
break;
|
|
788
|
+
case "open":
|
|
789
|
+
await this.web.navigate(payload.url);
|
|
790
|
+
break;
|
|
791
|
+
case "click":
|
|
792
|
+
await this.web.click(payload.selector, payload.elementType);
|
|
793
|
+
break;
|
|
794
|
+
case "forceClick":
|
|
795
|
+
await this.web.forceClick(payload.selector, payload.elementType);
|
|
796
|
+
break;
|
|
797
|
+
case "type":
|
|
798
|
+
await this.web.type(payload.selector, payload.text, payload.elementType);
|
|
799
|
+
break;
|
|
800
|
+
case "forceType":
|
|
801
|
+
await this.web.forceType(payload.selector, payload.text, payload.elementType);
|
|
802
|
+
break;
|
|
803
|
+
case "passcode":
|
|
804
|
+
await this.web.passcode(payload.selector, payload.text, payload.elementType);
|
|
805
|
+
break;
|
|
806
|
+
case "check":
|
|
807
|
+
await this.web.check(payload.selector, payload.elementType);
|
|
808
|
+
break;
|
|
809
|
+
case "uncheck":
|
|
810
|
+
await this.web.uncheck(payload.selector, payload.elementType);
|
|
811
|
+
break;
|
|
812
|
+
case "upload":
|
|
813
|
+
await this.web.upload(payload.selector, payload.file, payload.elementType);
|
|
814
|
+
break;
|
|
815
|
+
case "set":
|
|
816
|
+
await this.web.set(payload.selector, payload.value, payload.elementType);
|
|
817
|
+
break;
|
|
818
|
+
case "select":
|
|
819
|
+
await this.web.select(payload.option, payload.selector, payload.elementType);
|
|
820
|
+
break;
|
|
821
|
+
case "drag":
|
|
822
|
+
await this.web.drag(payload.source, payload.destination);
|
|
823
|
+
break;
|
|
824
|
+
case "switchTo":
|
|
825
|
+
await this.web.switchTo(payload.target);
|
|
826
|
+
await this.startStreaming();
|
|
827
|
+
break;
|
|
828
|
+
case "acceptDialog":
|
|
829
|
+
await this.web.acceptDialog();
|
|
830
|
+
break;
|
|
831
|
+
case "rejectDialog":
|
|
832
|
+
await this.web.rejectDialog();
|
|
833
|
+
break;
|
|
834
|
+
case "close":
|
|
835
|
+
await this.web.close(payload.selector);
|
|
836
|
+
break;
|
|
837
|
+
case "injectAudio":
|
|
838
|
+
await this.web.injectAudio(payload.file);
|
|
839
|
+
break;
|
|
840
|
+
case "waitForPipeline":
|
|
841
|
+
await this.waitForPipeline();
|
|
842
|
+
break;
|
|
843
|
+
case "expect":
|
|
844
|
+
if (["status", "responsetime", "resbody", "resheader", "cookie"].includes(payload.type)) {
|
|
845
|
+
const apiState = this.api.getState();
|
|
846
|
+
if (payload.type === "status") {
|
|
847
|
+
if (String(apiState.status) !== String(payload.statusCode))
|
|
848
|
+
throw new Error(`API Assertion Failed: Expected HTTP status ${payload.statusCode}, but got ${apiState.status}`);
|
|
849
|
+
}
|
|
850
|
+
else if (payload.type === "responsetime") {
|
|
851
|
+
if (apiState.responseTime > parseInt(payload.threshold, 10))
|
|
852
|
+
throw new Error(`API Assertion Failed: Expected response time to be less than ${payload.threshold}ms, but got ${apiState.responseTime}ms`);
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
let actualValue;
|
|
856
|
+
if (payload.type === "resbody")
|
|
857
|
+
actualValue = this.extractFromJsonPath(apiState.body, payload.jsonPath);
|
|
858
|
+
else if (payload.type === "resheader")
|
|
859
|
+
actualValue = apiState.headers?.[payload.jsonPath.toLowerCase()];
|
|
860
|
+
else if (payload.type === "cookie") {
|
|
861
|
+
const cookies = apiState.headers?.["set-cookie"];
|
|
862
|
+
if (Array.isArray(cookies)) {
|
|
863
|
+
const match = cookies.find((c) => c.startsWith(`${payload.jsonPath}=`));
|
|
864
|
+
if (match)
|
|
865
|
+
actualValue = match.split("=")[1].split(";")[0];
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const assertion of payload.assertions) {
|
|
869
|
+
const condition = assertion.condition;
|
|
870
|
+
const rawExpected = assertion.value;
|
|
871
|
+
const expectedLower = String(rawExpected || "").toLowerCase();
|
|
872
|
+
if (condition === "type.of" || condition === "format") {
|
|
873
|
+
let isValid = false;
|
|
874
|
+
const strActual = String(actualValue);
|
|
875
|
+
if (expectedLower === "email")
|
|
876
|
+
isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(strActual);
|
|
877
|
+
else if (expectedLower === "uuid")
|
|
878
|
+
isValid =
|
|
879
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(strActual);
|
|
880
|
+
else if (expectedLower === "url") {
|
|
881
|
+
try {
|
|
882
|
+
new URL(strActual);
|
|
883
|
+
isValid = true;
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
isValid = false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
else if (expectedLower === "date")
|
|
890
|
+
isValid = !isNaN(Date.parse(strActual));
|
|
891
|
+
else if (expectedLower === "hex")
|
|
892
|
+
isValid = /^#?([a-f0-9]{6}|[a-f0-9]{3})$/i.test(strActual);
|
|
893
|
+
else {
|
|
894
|
+
let actualType = typeof actualValue;
|
|
895
|
+
if (actualValue === null)
|
|
896
|
+
actualType = "null";
|
|
897
|
+
else if (Array.isArray(actualValue))
|
|
898
|
+
actualType = "array";
|
|
899
|
+
isValid = actualType === expectedLower;
|
|
900
|
+
}
|
|
901
|
+
if (!isValid)
|
|
902
|
+
throw new Error(`API Assertion Failed: Expected "${payload.jsonPath}" to be ${expectedLower}, but got "${actualValue}"`);
|
|
903
|
+
}
|
|
904
|
+
else if (condition === "matches") {
|
|
905
|
+
const regex = new RegExp(rawExpected);
|
|
906
|
+
if (!regex.test(String(actualValue))) {
|
|
907
|
+
throw new Error(`API Assertion Failed: Expected "${payload.jsonPath}" to match regex "${rawExpected}", but got "${String(actualValue)}"`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else if (["greaterthan", "lessthan"].includes(condition)) {
|
|
911
|
+
const numActual = Number(actualValue);
|
|
912
|
+
const numExpected = Number(rawExpected);
|
|
913
|
+
if (isNaN(numActual) || isNaN(numExpected))
|
|
914
|
+
throw new Error(`API Assertion Failed: Numeric comparison requires valid numbers.`);
|
|
915
|
+
if (condition === "greaterthan" && !(numActual > numExpected))
|
|
916
|
+
throw new Error(`API Assertion Failed: Expected ${numActual} > ${numExpected}`);
|
|
917
|
+
if (condition === "lessthan" && !(numActual < numExpected))
|
|
918
|
+
throw new Error(`API Assertion Failed: Expected ${numActual} < ${numExpected}`);
|
|
919
|
+
}
|
|
920
|
+
else if (condition.startsWith("length")) {
|
|
921
|
+
const len = Array.isArray(actualValue) || typeof actualValue === "string"
|
|
922
|
+
? actualValue.length
|
|
923
|
+
: undefined;
|
|
924
|
+
if (len === undefined)
|
|
925
|
+
throw new Error(`API Assertion Failed: "${payload.jsonPath}" has no length.`);
|
|
926
|
+
const numExpected = Number(rawExpected);
|
|
927
|
+
if (condition === "length" && len !== numExpected)
|
|
928
|
+
throw new Error(`API Assertion Failed: Expected length ${numExpected}, got ${len}`);
|
|
929
|
+
if (condition === "length.greaterthan" && !(len > numExpected))
|
|
930
|
+
throw new Error(`API Assertion Failed: Expected length > ${numExpected}, got ${len}`);
|
|
931
|
+
if (condition === "length.lessthan" && !(len < numExpected))
|
|
932
|
+
throw new Error(`API Assertion Failed: Expected length < ${numExpected}, got ${len}`);
|
|
933
|
+
}
|
|
934
|
+
else if (["includes", "does.not.include"].includes(condition)) {
|
|
935
|
+
const isArrayOrString = Array.isArray(actualValue) || typeof actualValue === "string";
|
|
936
|
+
if (!isArrayOrString)
|
|
937
|
+
throw new Error(`API Assertion Failed: Cannot check inclusion on non-collection type.`);
|
|
938
|
+
const hasItem = actualValue.includes(rawExpected);
|
|
939
|
+
if (condition === "includes" && !hasItem)
|
|
940
|
+
throw new Error(`API Assertion Failed: Expected inclusion of "${rawExpected}"`);
|
|
941
|
+
if (condition === "does.not.include" && hasItem)
|
|
942
|
+
throw new Error(`API Assertion Failed: Expected exclusion of "${rawExpected}"`);
|
|
943
|
+
}
|
|
944
|
+
else if (["has.key", "does.not.have.key"].includes(condition)) {
|
|
945
|
+
if (typeof actualValue !== "object" || actualValue === null)
|
|
946
|
+
throw new Error(`API Assertion Failed: Key check requires object.`);
|
|
947
|
+
const hasKey = rawExpected in actualValue;
|
|
948
|
+
if (condition === "has.key" && !hasKey)
|
|
949
|
+
throw new Error(`API Assertion Failed: Object missing key "${rawExpected}"`);
|
|
950
|
+
if (condition === "does.not.have.key" && hasKey)
|
|
951
|
+
throw new Error(`API Assertion Failed: Object should not contain key "${rawExpected}"`);
|
|
952
|
+
}
|
|
953
|
+
else if (["is.empty", "not.empty"].includes(condition)) {
|
|
954
|
+
const isEmpty = actualValue === undefined ||
|
|
955
|
+
actualValue === null ||
|
|
956
|
+
actualValue === "" ||
|
|
957
|
+
(Array.isArray(actualValue) && actualValue.length === 0) ||
|
|
958
|
+
(typeof actualValue === "object" &&
|
|
959
|
+
Object.keys(actualValue).length === 0);
|
|
960
|
+
const isNegated = condition === "not.empty";
|
|
961
|
+
const passed = isNegated ? !isEmpty : isEmpty;
|
|
962
|
+
if (!passed)
|
|
963
|
+
throw new Error(`API Assertion Failed: Expected "${payload.jsonPath}" to be ${isNegated ? "not empty" : "empty"}, but got "${actualValue}"`);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
const isNegated = ["to.not.be", "is.not"].includes(condition);
|
|
967
|
+
const isToBe = ["to.be", "is"].includes(condition);
|
|
968
|
+
const knownTypes = [
|
|
969
|
+
"string",
|
|
970
|
+
"number",
|
|
971
|
+
"boolean",
|
|
972
|
+
"array",
|
|
973
|
+
"object",
|
|
974
|
+
"null",
|
|
975
|
+
"undefined",
|
|
976
|
+
];
|
|
977
|
+
if (["equals", "contains"].includes(condition) ||
|
|
978
|
+
(isToBe && !knownTypes.includes(expectedLower))) {
|
|
979
|
+
const strActual = String(actualValue);
|
|
980
|
+
const isEquals = condition === "equals" ||
|
|
981
|
+
(isToBe && !knownTypes.includes(expectedLower));
|
|
982
|
+
if (isEquals) {
|
|
983
|
+
const match = strActual === rawExpected;
|
|
984
|
+
const passed = isNegated ? !match : match;
|
|
985
|
+
if (!passed)
|
|
986
|
+
throw new Error(`API Assertion Failed: Expected "${payload.jsonPath}" ${isNegated ? "not to equal" : "to equal"} "${rawExpected}", but got "${strActual}"`);
|
|
987
|
+
}
|
|
988
|
+
else if (condition === "contains") {
|
|
989
|
+
const match = strActual.includes(rawExpected);
|
|
990
|
+
if (!match)
|
|
991
|
+
throw new Error(`API Assertion Failed: Expected "${strActual}" to contain "${rawExpected}"`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else if (isToBe || isNegated) {
|
|
995
|
+
let checkPassed = false;
|
|
996
|
+
let actualType = typeof actualValue;
|
|
997
|
+
if (actualValue === null)
|
|
998
|
+
actualType = "null";
|
|
999
|
+
else if (Array.isArray(actualValue))
|
|
1000
|
+
actualType = "array";
|
|
1001
|
+
else if (actualValue === undefined)
|
|
1002
|
+
actualType = "undefined";
|
|
1003
|
+
checkPassed = isNegated
|
|
1004
|
+
? actualType !== expectedLower
|
|
1005
|
+
: actualType === expectedLower;
|
|
1006
|
+
if (!checkPassed) {
|
|
1007
|
+
const expectedStr = isNegated
|
|
1008
|
+
? `not to be "${expectedLower}"`
|
|
1009
|
+
: `to be "${expectedLower}"`;
|
|
1010
|
+
throw new Error(`API Assertion Failed: Expected "${payload.jsonPath}" ${expectedStr}, but got type "${actualType}".`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
else if (payload.type === "visible")
|
|
1018
|
+
await this.web.expectVisible(payload.selector, payload.elementType);
|
|
1019
|
+
else if (payload.type === "text")
|
|
1020
|
+
await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
|
|
1021
|
+
else if (payload.type === "value")
|
|
1022
|
+
await this.web.expectValue(payload.selector, payload.text, payload.elementType);
|
|
1023
|
+
else if (payload.type === "transcript")
|
|
1024
|
+
await this.web.expectTranscript(payload.text);
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
async setupNetworkInterception() {
|
|
1029
|
+
const context = this.web.getPage().context();
|
|
1030
|
+
context.on("response", async (response) => {
|
|
1031
|
+
const lockedFileName = this.currentFileName;
|
|
1032
|
+
const request = response.request();
|
|
1033
|
+
const type = request.resourceType();
|
|
1034
|
+
if (type !== "fetch" && type !== "xhr")
|
|
1035
|
+
return;
|
|
1036
|
+
let responseBody = null;
|
|
1037
|
+
try {
|
|
1038
|
+
const text = await response.text();
|
|
1039
|
+
responseBody = JSON.parse(text);
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
responseBody = "[Body capture failed]";
|
|
1043
|
+
}
|
|
1044
|
+
let requestBody = null;
|
|
1045
|
+
let rawPostData = null;
|
|
1046
|
+
try {
|
|
1047
|
+
rawPostData = request.postData();
|
|
1048
|
+
if (rawPostData)
|
|
1049
|
+
requestBody = JSON.parse(rawPostData);
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
requestBody = "[Request body capture failed]";
|
|
1053
|
+
}
|
|
1054
|
+
const sharedId = Math.random().toString(36).substr(2, 9);
|
|
1055
|
+
const urlObj = new URL(request.url());
|
|
1056
|
+
const endpointName = urlObj.pathname.split("/").pop() || urlObj.pathname;
|
|
1057
|
+
const curlString = this.web.synthesizeCurl(request.method(), request.url(), request.headers(), rawPostData);
|
|
1058
|
+
this.emitLog("REST", response.status() < 400 ? "success" : "error", `${request.method()} ${endpointName}`, {
|
|
1059
|
+
method: request.method(),
|
|
1060
|
+
url: request.url(),
|
|
1061
|
+
requestHeaders: request.headers(),
|
|
1062
|
+
requestBody: requestBody,
|
|
1063
|
+
responseStatus: response.status(),
|
|
1064
|
+
responseHeaders: response.headers(),
|
|
1065
|
+
responseBody: responseBody,
|
|
1066
|
+
curlString: curlString,
|
|
1067
|
+
}, sharedId, lockedFileName);
|
|
1068
|
+
this.broadcast("NETWORK_REQ", {
|
|
1069
|
+
id: sharedId,
|
|
1070
|
+
method: request.method(),
|
|
1071
|
+
url: request.url(),
|
|
1072
|
+
name: endpointName,
|
|
1073
|
+
status: response.status(),
|
|
1074
|
+
curlString: curlString,
|
|
1075
|
+
time: new Date().toLocaleTimeString([], {
|
|
1076
|
+
hour12: false,
|
|
1077
|
+
hour: "2-digit",
|
|
1078
|
+
minute: "2-digit",
|
|
1079
|
+
second: "2-digit",
|
|
1080
|
+
}),
|
|
1081
|
+
fileName: lockedFileName,
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
async startStreaming() {
|
|
1086
|
+
const page = this.web.getPage();
|
|
1087
|
+
if (this.currentCdpSession) {
|
|
1088
|
+
await this.currentCdpSession.detach().catch(() => { });
|
|
1089
|
+
}
|
|
1090
|
+
this.currentCdpSession = await page.context().newCDPSession(page);
|
|
1091
|
+
await this.currentCdpSession.send("Page.startScreencast", {
|
|
1092
|
+
format: "jpeg",
|
|
1093
|
+
quality: 60,
|
|
1094
|
+
});
|
|
1095
|
+
this.currentCdpSession.on("Page.screencastFrame", ({ data, sessionId }) => {
|
|
1096
|
+
this.broadcast("BROWSER_FRAME", {
|
|
1097
|
+
base64: data,
|
|
1098
|
+
fileName: this.currentFileName,
|
|
1099
|
+
});
|
|
1100
|
+
this.currentCdpSession
|
|
1101
|
+
.send("Page.screencastFrameAck", { sessionId })
|
|
1102
|
+
.catch(() => { });
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
async shutdown() {
|
|
1106
|
+
try {
|
|
1107
|
+
await this.web.shutdown();
|
|
1108
|
+
}
|
|
1109
|
+
catch (e) { }
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
exports.FlowWorker = FlowWorker;
|