flowstride 1.0.11 → 1.0.12
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/dist/adapters/web/playwright.d.ts +1 -0
- package/dist/adapters/web/playwright.js +60 -0
- package/dist/cli/index.js +1 -1
- package/dist/parser/lexer.js +25 -8
- package/dist/parser/parser.d.ts +6 -0
- package/dist/parser/parser.js +106 -30
- package/dist/runner/flow_orchestrator.d.ts +1 -0
- package/dist/runner/flow_orchestrator.js +20 -5
- package/dist/runner/flow_worker.d.ts +1 -0
- package/dist/runner/flow_worker.js +204 -131
- package/package.json +1 -1
|
@@ -26,6 +26,7 @@ export declare class WebAdapter {
|
|
|
26
26
|
private checkAmbiguity;
|
|
27
27
|
close(targetText: string): Promise<void>;
|
|
28
28
|
click(selector: string, elementType?: string): Promise<void>;
|
|
29
|
+
hover(selector: string, elementType?: string): Promise<void>;
|
|
29
30
|
type(selector: string, value: string, elementType?: string): Promise<void>;
|
|
30
31
|
passcode(selector: string, value: string, elementType?: string): Promise<void>;
|
|
31
32
|
check(selector: string, elementType?: string): Promise<void>;
|
|
@@ -397,6 +397,66 @@ class WebAdapter {
|
|
|
397
397
|
await targetLoc.scrollIntoViewIfNeeded();
|
|
398
398
|
await targetLoc.click();
|
|
399
399
|
}
|
|
400
|
+
async hover(selector, 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
|
+
// 1. Accessibility WAI-ARIA Hack (Focus)
|
|
406
|
+
await targetLoc.focus({ timeout: 3000 }).catch(() => { });
|
|
407
|
+
await targetLoc
|
|
408
|
+
.evaluate((node) => {
|
|
409
|
+
if (typeof node.focus === "function")
|
|
410
|
+
node.focus();
|
|
411
|
+
})
|
|
412
|
+
.catch(() => { });
|
|
413
|
+
// 2. Get Exact Center Coordinates for Physical Move
|
|
414
|
+
let box = null;
|
|
415
|
+
try {
|
|
416
|
+
box = await targetLoc.boundingBox({ timeout: 3000 });
|
|
417
|
+
}
|
|
418
|
+
catch (e) { } // Catch in case it mutated instantly during focus
|
|
419
|
+
if (box) {
|
|
420
|
+
const centerX = box.x + box.width / 2;
|
|
421
|
+
const centerY = box.y + box.height / 2;
|
|
422
|
+
// Physically drag the mouse to the element to satisfy Hover Intent APIs
|
|
423
|
+
await this.page.mouse.move(centerX, centerY, { steps: 5 });
|
|
424
|
+
}
|
|
425
|
+
// 3. Playwright Native Hover Backup (Caught to prevent mutation crashes)
|
|
426
|
+
await targetLoc.hover({ force: true, timeout: 3000 }).catch(() => { });
|
|
427
|
+
// 4. The Ultimate Wakeup Call: Synthesize Full Coordinate-Loaded Events
|
|
428
|
+
await targetLoc
|
|
429
|
+
.evaluate((node) => {
|
|
430
|
+
const rect = node.getBoundingClientRect();
|
|
431
|
+
const x = rect.left + rect.width / 2;
|
|
432
|
+
const y = rect.top + rect.height / 2;
|
|
433
|
+
const events = [
|
|
434
|
+
"pointerover",
|
|
435
|
+
"pointerenter",
|
|
436
|
+
"mouseover",
|
|
437
|
+
"mouseenter",
|
|
438
|
+
"mousemove",
|
|
439
|
+
];
|
|
440
|
+
events.forEach((type) => {
|
|
441
|
+
node.dispatchEvent(new MouseEvent(type, {
|
|
442
|
+
view: window,
|
|
443
|
+
bubbles: true,
|
|
444
|
+
cancelable: true,
|
|
445
|
+
clientX: x,
|
|
446
|
+
clientY: y,
|
|
447
|
+
screenX: x,
|
|
448
|
+
screenY: y,
|
|
449
|
+
}));
|
|
450
|
+
});
|
|
451
|
+
})
|
|
452
|
+
.catch(() => { });
|
|
453
|
+
// 5. We perform a programmatic click that won't trigger W3C navigation if handled by React toggles
|
|
454
|
+
await targetLoc
|
|
455
|
+
.click({ delay: 50, force: true, timeout: 3000 })
|
|
456
|
+
.catch(() => { });
|
|
457
|
+
// 6. The Paint Buffer
|
|
458
|
+
await this.page.waitForTimeout(600);
|
|
459
|
+
}
|
|
400
460
|
async type(selector, value, elementType) {
|
|
401
461
|
const loc = await this.getLocator(selector, elementType, false, false);
|
|
402
462
|
const targetLoc = await this.checkAmbiguity(loc, selector);
|
package/dist/cli/index.js
CHANGED
|
@@ -54,7 +54,7 @@ const API_BASE = process.env.FLOWSTRIDE_API_URL || "https://cloud.flowstride.io"
|
|
|
54
54
|
program
|
|
55
55
|
.name("flowstride")
|
|
56
56
|
.description("Flowstride: The Enterprise Flow-First Automation CLI")
|
|
57
|
-
.version("1.0.
|
|
57
|
+
.version("1.0.12");
|
|
58
58
|
program
|
|
59
59
|
.command("init")
|
|
60
60
|
.description("Initialise a new Flowstride workspace in the current directory")
|
package/dist/parser/lexer.js
CHANGED
|
@@ -129,7 +129,9 @@ class Lexer {
|
|
|
129
129
|
tokens.push({ type: TokenType.LET, value: ident, line: this.line });
|
|
130
130
|
continue;
|
|
131
131
|
}
|
|
132
|
-
if (lowerIdent === "feature" ||
|
|
132
|
+
if (lowerIdent === "feature" ||
|
|
133
|
+
lowerIdent === "scenario" ||
|
|
134
|
+
lowerIdent === "only.scenario") {
|
|
133
135
|
let tempPos = this.position;
|
|
134
136
|
while (tempPos < this.input.length &&
|
|
135
137
|
(this.input[tempPos] === " " || this.input[tempPos] === "\t"))
|
|
@@ -205,6 +207,7 @@ class Lexer {
|
|
|
205
207
|
"autoheal",
|
|
206
208
|
"mail.getotp",
|
|
207
209
|
"extract",
|
|
210
|
+
"hover",
|
|
208
211
|
];
|
|
209
212
|
if (validNakedCommands.map((c) => c.toLowerCase()).includes(lowerIdent)) {
|
|
210
213
|
tokens.push({
|
|
@@ -226,7 +229,16 @@ class Lexer {
|
|
|
226
229
|
throw new SyntaxError(`Syntax Error on line ${this.line}: Expected an action command after 'flow.try', but found "${ident}".`);
|
|
227
230
|
}
|
|
228
231
|
}
|
|
229
|
-
if ([
|
|
232
|
+
if ([
|
|
233
|
+
"given",
|
|
234
|
+
"when",
|
|
235
|
+
"then",
|
|
236
|
+
"and",
|
|
237
|
+
"stop.given",
|
|
238
|
+
"stop.when",
|
|
239
|
+
"stop.then",
|
|
240
|
+
"stop.and",
|
|
241
|
+
].includes(lowerIdent)) {
|
|
230
242
|
let tempPos = this.position;
|
|
231
243
|
while (tempPos < this.input.length &&
|
|
232
244
|
(this.input[tempPos] === " " || this.input[tempPos] === "\t"))
|
|
@@ -268,6 +280,7 @@ class Lexer {
|
|
|
268
280
|
"flow.autoheal",
|
|
269
281
|
"flow.mail.getotp",
|
|
270
282
|
"flow.extract",
|
|
283
|
+
"flow.hover",
|
|
271
284
|
]
|
|
272
285
|
.map((c) => c.toLowerCase())
|
|
273
286
|
.includes(lowerIdent)) {
|
|
@@ -290,14 +303,16 @@ class Lexer {
|
|
|
290
303
|
"contains",
|
|
291
304
|
"placeholder",
|
|
292
305
|
"status",
|
|
293
|
-
"
|
|
294
|
-
"
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
"
|
|
306
|
+
"responseTime",
|
|
307
|
+
"reqBody",
|
|
308
|
+
"resBody",
|
|
309
|
+
"reqHeader",
|
|
310
|
+
"resHeader",
|
|
298
311
|
"cookie",
|
|
299
312
|
"global",
|
|
300
|
-
]
|
|
313
|
+
]
|
|
314
|
+
.map((c) => c.toLowerCase())
|
|
315
|
+
.includes(lowerIdent)) {
|
|
301
316
|
tokens.push({
|
|
302
317
|
type: TokenType.MODIFIER,
|
|
303
318
|
value: ident,
|
|
@@ -314,6 +329,8 @@ class Lexer {
|
|
|
314
329
|
"span",
|
|
315
330
|
"image",
|
|
316
331
|
"img",
|
|
332
|
+
"burger",
|
|
333
|
+
"hamburger",
|
|
317
334
|
].includes(lowerIdent)) {
|
|
318
335
|
tokens.push({
|
|
319
336
|
type: TokenType.ELEMENT,
|
package/dist/parser/parser.d.ts
CHANGED
|
@@ -10,11 +10,13 @@ export interface StepNode {
|
|
|
10
10
|
action: string;
|
|
11
11
|
payload: any;
|
|
12
12
|
line: number;
|
|
13
|
+
isStop?: boolean;
|
|
13
14
|
}
|
|
14
15
|
export interface ScenarioNode {
|
|
15
16
|
name: string;
|
|
16
17
|
declarations: DeclarationNode[];
|
|
17
18
|
steps: StepNode[];
|
|
19
|
+
isOnly?: boolean;
|
|
18
20
|
}
|
|
19
21
|
export interface FeatureNode {
|
|
20
22
|
name: string;
|
|
@@ -26,6 +28,10 @@ export declare class Parser {
|
|
|
26
28
|
private position;
|
|
27
29
|
private currentBlockDescription;
|
|
28
30
|
private stepCounter;
|
|
31
|
+
private scenarioCounter;
|
|
32
|
+
private hasOnlyScenario;
|
|
33
|
+
private hasStopInCurrentScenario;
|
|
34
|
+
private currentBlockIsStop;
|
|
29
35
|
constructor(tokens: Token[]);
|
|
30
36
|
parse(): FeatureNode[];
|
|
31
37
|
private parseDeclaration;
|
package/dist/parser/parser.js
CHANGED
|
@@ -7,6 +7,10 @@ class Parser {
|
|
|
7
7
|
position = 0;
|
|
8
8
|
currentBlockDescription = "";
|
|
9
9
|
stepCounter = 1;
|
|
10
|
+
scenarioCounter = 0;
|
|
11
|
+
hasOnlyScenario = false;
|
|
12
|
+
hasStopInCurrentScenario = false;
|
|
13
|
+
currentBlockIsStop = false;
|
|
10
14
|
constructor(tokens) {
|
|
11
15
|
this.tokens = tokens;
|
|
12
16
|
}
|
|
@@ -68,15 +72,25 @@ class Parser {
|
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
74
|
parseScenario() {
|
|
71
|
-
this.advance();
|
|
75
|
+
const scenarioToken = this.advance();
|
|
76
|
+
const isOnly = scenarioToken.value.toLowerCase() === "only.scenario";
|
|
77
|
+
if (isOnly) {
|
|
78
|
+
if (this.hasOnlyScenario) {
|
|
79
|
+
throw new Error(`Line ${scenarioToken.line}: You cannot have more than one 'only.Scenario' in a single file.`);
|
|
80
|
+
}
|
|
81
|
+
this.hasOnlyScenario = true;
|
|
82
|
+
}
|
|
83
|
+
this.hasStopInCurrentScenario = false;
|
|
72
84
|
const nameToken = this.consume(lexer_1.TokenType.STRING, "Scenario must have a description");
|
|
73
85
|
const steps = [];
|
|
74
86
|
const declarations = [];
|
|
87
|
+
this.scenarioCounter++;
|
|
75
88
|
this.stepCounter = 1;
|
|
76
89
|
let state = "START";
|
|
77
90
|
let hasGiven = false, hasWhen = false, hasAnd = false, hasThen = false;
|
|
78
91
|
let isLocked = false;
|
|
79
92
|
let currentBlockType = "";
|
|
93
|
+
this.currentBlockIsStop = false;
|
|
80
94
|
while (!this.isAtEnd() &&
|
|
81
95
|
this.peek().type !== lexer_1.TokenType.SCENARIO &&
|
|
82
96
|
this.peek().type !== lexer_1.TokenType.FEATURE) {
|
|
@@ -88,7 +102,19 @@ class Parser {
|
|
|
88
102
|
if (isLocked)
|
|
89
103
|
throw new Error(`Line ${token.line}: Terminal Lock Violation. No steps allowed after Then.`);
|
|
90
104
|
const blockToken = this.advance();
|
|
91
|
-
|
|
105
|
+
let rawBlockType = blockToken.value.toLowerCase();
|
|
106
|
+
if (rawBlockType.startsWith("stop.")) {
|
|
107
|
+
if (this.hasStopInCurrentScenario) {
|
|
108
|
+
throw new Error(`Line ${blockToken.line}: You cannot have more than one 'stop.' block in a single scenario.`);
|
|
109
|
+
}
|
|
110
|
+
this.hasStopInCurrentScenario = true;
|
|
111
|
+
this.currentBlockIsStop = true;
|
|
112
|
+
rawBlockType = rawBlockType.replace("stop.", "");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
this.currentBlockIsStop = false;
|
|
116
|
+
}
|
|
117
|
+
const blockType = rawBlockType;
|
|
92
118
|
let description = this.peek().type === lexer_1.TokenType.STRING ? this.advance().value : "";
|
|
93
119
|
if (blockType === "given") {
|
|
94
120
|
if (state !== "START")
|
|
@@ -116,9 +142,10 @@ class Parser {
|
|
|
116
142
|
hasAnd = true;
|
|
117
143
|
}
|
|
118
144
|
currentBlockType = blockType;
|
|
145
|
+
const displayBlockName = blockType.charAt(0).toUpperCase() + blockType.slice(1);
|
|
119
146
|
this.currentBlockDescription = description
|
|
120
|
-
? `${
|
|
121
|
-
:
|
|
147
|
+
? `${displayBlockName} "${description}"`
|
|
148
|
+
: displayBlockName;
|
|
122
149
|
}
|
|
123
150
|
else if (token.type === lexer_1.TokenType.COMMAND ||
|
|
124
151
|
token.type === lexer_1.TokenType.ALIAS_COMMAND) {
|
|
@@ -126,6 +153,9 @@ class Parser {
|
|
|
126
153
|
throw new Error(`Line ${token.line}: Terminal Lock Violation.`);
|
|
127
154
|
}
|
|
128
155
|
const step = this.parseCommand();
|
|
156
|
+
if (this.currentBlockIsStop) {
|
|
157
|
+
step.isStop = true;
|
|
158
|
+
}
|
|
129
159
|
if (currentBlockType === "and" &&
|
|
130
160
|
step.action !== "expect" &&
|
|
131
161
|
step.action !== "plugin") {
|
|
@@ -140,7 +170,15 @@ class Parser {
|
|
|
140
170
|
if (!hasGiven || !hasWhen || !hasThen) {
|
|
141
171
|
throw new Error(`Scenario "${nameToken.value}" is missing mandatory blocks.`);
|
|
142
172
|
}
|
|
143
|
-
|
|
173
|
+
const scenarioNode = {
|
|
174
|
+
name: nameToken.value,
|
|
175
|
+
declarations,
|
|
176
|
+
steps,
|
|
177
|
+
};
|
|
178
|
+
if (isOnly) {
|
|
179
|
+
scenarioNode.isOnly = true;
|
|
180
|
+
}
|
|
181
|
+
return scenarioNode;
|
|
144
182
|
}
|
|
145
183
|
parseSmartSelectorPayload() {
|
|
146
184
|
let selector = "";
|
|
@@ -202,7 +240,7 @@ class Parser {
|
|
|
202
240
|
dataPayload = this.advance().value;
|
|
203
241
|
}
|
|
204
242
|
return {
|
|
205
|
-
id: `
|
|
243
|
+
id: `scenario_${this.scenarioCounter}_S${this.stepCounter++}`,
|
|
206
244
|
blockDescription: this.currentBlockDescription,
|
|
207
245
|
action: "plugin",
|
|
208
246
|
payload: {
|
|
@@ -259,26 +297,32 @@ class Parser {
|
|
|
259
297
|
autoheal: "autoHeal",
|
|
260
298
|
"mail.getotp": "getOtp",
|
|
261
299
|
extract: "extract",
|
|
300
|
+
hover: "hover",
|
|
262
301
|
};
|
|
263
302
|
const action = actionMap[actionBase] || actionBase;
|
|
264
303
|
let payload = { optional: isOptional };
|
|
265
304
|
switch (action) {
|
|
266
305
|
case "extract":
|
|
267
306
|
const extTargetToken = this.consume(lexer_1.TokenType.MODIFIER, "Expected target after extract");
|
|
268
|
-
const extTarget = extTargetToken.value
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
307
|
+
const extTarget = extTargetToken.value;
|
|
308
|
+
if (extTarget.toLowerCase() === "resbody" && extTarget !== "resBody") {
|
|
309
|
+
throw new Error(`Syntax Error on line ${extTargetToken.line}: Invalid keyword '${extTarget}'. Flowstride uses strict camelCase. Did you mean 'resBody'?`);
|
|
310
|
+
}
|
|
311
|
+
if (extTarget.toLowerCase() === "reqbody" && extTarget !== "reqBody") {
|
|
312
|
+
throw new Error(`Syntax Error on line ${extTargetToken.line}: Invalid keyword '${extTarget}'. Flowstride uses strict camelCase. Did you mean 'reqBody'?`);
|
|
313
|
+
}
|
|
314
|
+
if (extTarget.toLowerCase() === "resheader" &&
|
|
315
|
+
extTarget !== "resHeader") {
|
|
316
|
+
throw new Error(`Syntax Error on line ${extTargetToken.line}: Invalid keyword '${extTarget}'. Flowstride uses strict camelCase. Did you mean 'resHeader'?`);
|
|
317
|
+
}
|
|
318
|
+
if (extTarget.toLowerCase() === "reqheader" &&
|
|
319
|
+
extTarget !== "reqHeader") {
|
|
320
|
+
throw new Error(`Syntax Error on line ${extTargetToken.line}: Invalid keyword '${extTarget}'. Flowstride uses strict camelCase. Did you mean 'reqHeader'?`);
|
|
321
|
+
}
|
|
322
|
+
if (!["resBody", "resHeader", "cookie"].includes(extTarget)) {
|
|
279
323
|
throw new Error(`Line ${extTargetToken.line}: Expected 'resBody', 'resHeader', or 'cookie' after extract`);
|
|
280
324
|
}
|
|
281
|
-
payload.target =
|
|
325
|
+
payload.target = extTarget;
|
|
282
326
|
payload.jsonPath = this.consume(lexer_1.TokenType.STRING, "JSONPath or Key expected").value;
|
|
283
327
|
const asToken = this.advance();
|
|
284
328
|
if (asToken.value.toLowerCase() !== "as") {
|
|
@@ -333,8 +377,19 @@ class Parser {
|
|
|
333
377
|
case "forceClick":
|
|
334
378
|
case "check":
|
|
335
379
|
case "uncheck":
|
|
380
|
+
case "hover":
|
|
336
381
|
this.consumeOptionalElement(payload);
|
|
337
|
-
|
|
382
|
+
if (["burger", "hamburger"].includes(payload.elementType)) {
|
|
383
|
+
if (!this.isAtEnd() && this.peek().type === lexer_1.TokenType.STRING) {
|
|
384
|
+
payload.selector = this.parseSmartSelectorPayload();
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
payload.selector = "smart-burger-fallback";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
payload.selector = this.parseSmartSelectorPayload();
|
|
392
|
+
}
|
|
338
393
|
break;
|
|
339
394
|
case "type":
|
|
340
395
|
case "forceType":
|
|
@@ -426,16 +481,21 @@ class Parser {
|
|
|
426
481
|
break;
|
|
427
482
|
case "expect":
|
|
428
483
|
const modToken = this.consume(lexer_1.TokenType.MODIFIER, "Modifier expected");
|
|
429
|
-
|
|
430
|
-
if (mod === "resbody")
|
|
431
|
-
mod
|
|
432
|
-
|
|
433
|
-
|
|
484
|
+
const mod = modToken.value;
|
|
485
|
+
if (mod.toLowerCase() === "resbody" && mod !== "resBody") {
|
|
486
|
+
throw new Error(`Syntax Error on line ${modToken.line}: Invalid keyword '${mod}'. Flowstride uses strict camelCase. Did you mean 'resBody'?`);
|
|
487
|
+
}
|
|
488
|
+
if (mod.toLowerCase() === "resheader" && mod !== "resHeader") {
|
|
489
|
+
throw new Error(`Syntax Error on line ${modToken.line}: Invalid keyword '${mod}'. Flowstride uses strict camelCase. Did you mean 'resHeader'?`);
|
|
490
|
+
}
|
|
491
|
+
if (mod.toLowerCase() === "responsetime" && mod !== "responseTime") {
|
|
492
|
+
throw new Error(`Syntax Error on line ${modToken.line}: Invalid keyword '${mod}'. Flowstride uses strict camelCase. Did you mean 'responseTime'?`);
|
|
493
|
+
}
|
|
434
494
|
payload.type = mod;
|
|
435
495
|
if (mod === "status") {
|
|
436
496
|
payload.statusCode = this.consume(lexer_1.TokenType.STRING, 'Status code expected (e.g., "200")').value;
|
|
437
497
|
}
|
|
438
|
-
else if (mod === "
|
|
498
|
+
else if (mod === "responseTime") {
|
|
439
499
|
const conditionToken = this.advance();
|
|
440
500
|
if (conditionToken.value.toLowerCase() !== "lessthan") {
|
|
441
501
|
throw new Error(`Line ${modToken.line}: Expected 'lessThan' after responseTime`);
|
|
@@ -502,7 +562,17 @@ class Parser {
|
|
|
502
562
|
payload.fuzzy = true;
|
|
503
563
|
}
|
|
504
564
|
this.consumeOptionalElement(payload);
|
|
505
|
-
|
|
565
|
+
if (["burger", "hamburger"].includes(payload.elementType)) {
|
|
566
|
+
if (!this.isAtEnd() && this.peek().type === lexer_1.TokenType.STRING) {
|
|
567
|
+
payload.selector = this.parseSmartSelectorPayload();
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
payload.selector = "smart-burger-fallback";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
payload.selector = this.parseSmartSelectorPayload();
|
|
575
|
+
}
|
|
506
576
|
}
|
|
507
577
|
else if (mod === "value" || mod === "attribute") {
|
|
508
578
|
this.consumeOptionalElement(payload);
|
|
@@ -523,7 +593,7 @@ class Parser {
|
|
|
523
593
|
throw new Error(`Unknown command: ${action}`);
|
|
524
594
|
}
|
|
525
595
|
return {
|
|
526
|
-
id: `
|
|
596
|
+
id: `scenario_${this.scenarioCounter}_S${this.stepCounter++}`,
|
|
527
597
|
blockDescription: this.currentBlockDescription,
|
|
528
598
|
action,
|
|
529
599
|
payload,
|
|
@@ -537,13 +607,19 @@ class Parser {
|
|
|
537
607
|
if (nextToken.type === lexer_1.TokenType.PREPOSITION && nextVal === "with") {
|
|
538
608
|
this.advance();
|
|
539
609
|
const targetToken = this.advance();
|
|
540
|
-
const target = targetToken.value
|
|
541
|
-
if (target === "
|
|
610
|
+
const target = targetToken.value;
|
|
611
|
+
if (target.toLowerCase() === "reqbody" && target !== "reqBody") {
|
|
612
|
+
throw new Error(`Syntax Error on line ${targetToken.line}: Invalid keyword '${target}'. Flowstride uses strict camelCase. Did you mean 'reqBody'?`);
|
|
613
|
+
}
|
|
614
|
+
if (target.toLowerCase() === "reqheader" && target !== "reqHeader") {
|
|
615
|
+
throw new Error(`Syntax Error on line ${targetToken.line}: Invalid keyword '${target}'. Flowstride uses strict camelCase. Did you mean 'reqHeader'?`);
|
|
616
|
+
}
|
|
617
|
+
if (target === "reqHeader") {
|
|
542
618
|
const hKey = this.consume(lexer_1.TokenType.STRING, "Header key expected").value;
|
|
543
619
|
const hVal = this.consume(lexer_1.TokenType.STRING, "Header value expected").value;
|
|
544
620
|
payload.headers.push({ key: hKey, value: hVal });
|
|
545
621
|
}
|
|
546
|
-
else if (target === "
|
|
622
|
+
else if (target === "reqBody") {
|
|
547
623
|
payload.body = this.consume(lexer_1.TokenType.STRING, "Body content expected").value;
|
|
548
624
|
}
|
|
549
625
|
else {
|
|
@@ -9,6 +9,7 @@ export declare class FlowOrchestrator {
|
|
|
9
9
|
private globalTestScenarios;
|
|
10
10
|
private stepStatusRegistry;
|
|
11
11
|
private pdfGenerator;
|
|
12
|
+
private disconnectTimer;
|
|
12
13
|
constructor(worker: FlowWorker, workerCount?: number, config?: FlowstrideConfig | undefined);
|
|
13
14
|
private forwardToDashboard;
|
|
14
15
|
private executeTestRun;
|
|
@@ -53,6 +53,7 @@ class FlowOrchestrator {
|
|
|
53
53
|
globalTestScenarios = [];
|
|
54
54
|
stepStatusRegistry = new Map();
|
|
55
55
|
pdfGenerator;
|
|
56
|
+
disconnectTimer = null;
|
|
56
57
|
constructor(worker, workerCount = 1, config) {
|
|
57
58
|
this.worker = worker;
|
|
58
59
|
this.workerCount = workerCount;
|
|
@@ -222,6 +223,10 @@ class FlowOrchestrator {
|
|
|
222
223
|
});
|
|
223
224
|
this.wss = new ws_1.WebSocketServer({ server: this.httpServer });
|
|
224
225
|
this.wss.on("connection", (ws) => {
|
|
226
|
+
if (this.disconnectTimer) {
|
|
227
|
+
clearTimeout(this.disconnectTimer);
|
|
228
|
+
this.disconnectTimer = null;
|
|
229
|
+
}
|
|
225
230
|
ws.on("message", async (message) => {
|
|
226
231
|
const data = JSON.parse(message.toString());
|
|
227
232
|
if (data.type === "EXPORT_PDF_REQ") {
|
|
@@ -255,6 +260,21 @@ class FlowOrchestrator {
|
|
|
255
260
|
this.forwardToDashboard("RUN_COMPLETE", { status: "aborted" });
|
|
256
261
|
}
|
|
257
262
|
});
|
|
263
|
+
ws.on("close", () => {
|
|
264
|
+
if (this.wss && this.wss.clients.size === 0) {
|
|
265
|
+
this.disconnectTimer = setTimeout(async () => {
|
|
266
|
+
console.log(chalk_1.default.yellow("\n[Flowstride]: Dashboard disconnected. Shutting down framework..."));
|
|
267
|
+
this.worker.abort();
|
|
268
|
+
try {
|
|
269
|
+
await this.worker.shutdown();
|
|
270
|
+
}
|
|
271
|
+
catch (e) { }
|
|
272
|
+
this.wss?.close();
|
|
273
|
+
this.httpServer?.close();
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}, 3000);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
258
278
|
});
|
|
259
279
|
this.httpServer.listen(0, async () => {
|
|
260
280
|
const address = this.httpServer?.address();
|
|
@@ -273,11 +293,6 @@ class FlowOrchestrator {
|
|
|
273
293
|
console.log(chalk_1.default.green("[Dashboard]: Connected to UI!"));
|
|
274
294
|
await this.executeTestRun(targetPath);
|
|
275
295
|
});
|
|
276
|
-
process.on("SIGINT", () => {
|
|
277
|
-
this.wss?.close();
|
|
278
|
-
this.httpServer?.close();
|
|
279
|
-
process.exit();
|
|
280
|
-
});
|
|
281
296
|
}
|
|
282
297
|
catch (e) {
|
|
283
298
|
throw e;
|
|
@@ -55,6 +55,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
55
55
|
pluginManager;
|
|
56
56
|
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
57
57
|
isAborted = false;
|
|
58
|
+
isDebugHalted = false;
|
|
58
59
|
currentStepContext = "Initializing...";
|
|
59
60
|
currentFileName = "System";
|
|
60
61
|
activeAudioPath = undefined;
|
|
@@ -171,6 +172,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
171
172
|
extractFromJsonPath(obj, path) {
|
|
172
173
|
if (!obj)
|
|
173
174
|
return undefined;
|
|
175
|
+
if (!path)
|
|
176
|
+
return obj;
|
|
174
177
|
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
175
178
|
let current = obj;
|
|
176
179
|
for (const part of parts) {
|
|
@@ -186,11 +189,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
186
189
|
const state = this.api.getState();
|
|
187
190
|
for (const ext of extracts) {
|
|
188
191
|
let val;
|
|
189
|
-
if (ext.target === "
|
|
192
|
+
if (ext.target === "resBody") {
|
|
190
193
|
val = this.extractFromJsonPath(state.body, ext.jsonPath);
|
|
191
194
|
}
|
|
192
|
-
else if (ext.target === "
|
|
193
|
-
val = state.headers?.[ext.jsonPath.toLowerCase()];
|
|
195
|
+
else if (ext.target === "resHeader") {
|
|
196
|
+
val = state.headers?.[(ext.jsonPath || "").toLowerCase()];
|
|
194
197
|
}
|
|
195
198
|
else if (ext.target === "cookie") {
|
|
196
199
|
const cookies = state.headers?.["set-cookie"];
|
|
@@ -340,136 +343,174 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
340
343
|
this.config.headless = true;
|
|
341
344
|
this.activeAudioPath = undefined;
|
|
342
345
|
this.isAborted = false;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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;
|
|
346
|
+
this.isDebugHalted = false;
|
|
347
|
+
const sigintHandler = async () => {
|
|
348
|
+
if (!this.isSilent) {
|
|
349
|
+
console.log(chalk_1.default.yellow("\n[Flowstride]: Process interrupted. Gracefully shutting down..."));
|
|
354
350
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (this.isAborted)
|
|
358
|
-
break;
|
|
359
|
-
const fileStartTime = Date.now();
|
|
351
|
+
this.abort();
|
|
352
|
+
this.broadcast("SERVER_SHUTDOWN", { message: "Terminal closed." });
|
|
360
353
|
try {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
await this.setupNetworkInterception();
|
|
354
|
+
await this.web.shutdown();
|
|
355
|
+
}
|
|
356
|
+
catch (e) { }
|
|
357
|
+
process.exit(0);
|
|
358
|
+
};
|
|
359
|
+
process.once("SIGINT", sigintHandler);
|
|
360
|
+
try {
|
|
361
|
+
for (const filePath of filesToProcess) {
|
|
370
362
|
const content = fs.readFileSync(filePath, "utf8");
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
363
|
+
const audioMatch = content.match(/(?:flow\.)?injectAudio\s+["']([^"']+)["']/i);
|
|
364
|
+
if (audioMatch && audioMatch[1]) {
|
|
365
|
+
const rawPath = audioMatch[1];
|
|
366
|
+
const absolutePath = path.resolve(process.cwd(), rawPath);
|
|
367
|
+
if (!fs.existsSync(absolutePath)) {
|
|
368
|
+
throw new Error(`Pre-launch initialization failed: Audio file not found at ${absolutePath}`);
|
|
369
|
+
}
|
|
370
|
+
this.activeAudioPath = absolutePath;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const filePath of filesToProcess) {
|
|
375
|
+
if (this.isAborted || this.isDebugHalted)
|
|
376
|
+
break;
|
|
377
|
+
const fileStartTime = Date.now();
|
|
378
|
+
try {
|
|
379
|
+
const fileName = path.basename(filePath);
|
|
380
|
+
this.currentFileName = fileName;
|
|
381
|
+
this.localVariables = {};
|
|
382
|
+
this.localBrowserSessions = {};
|
|
383
|
+
this.api.resetState();
|
|
384
|
+
this.dataGen = new data_generators_1.DataGenerators();
|
|
385
|
+
await this.web.initialise(this.activeAudioPath);
|
|
386
|
+
await this.startStreaming();
|
|
387
|
+
await this.setupNetworkInterception();
|
|
388
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
389
|
+
const hasSessionIntent = content.includes("use session") ||
|
|
390
|
+
content.includes("use global session");
|
|
391
|
+
if (!hasSessionIntent) {
|
|
392
|
+
try {
|
|
393
|
+
const page = this.web.getPage();
|
|
394
|
+
if (page) {
|
|
395
|
+
await page
|
|
396
|
+
.context()
|
|
397
|
+
.clearCookies()
|
|
398
|
+
.catch(() => { });
|
|
399
|
+
await page
|
|
400
|
+
.evaluate(() => {
|
|
401
|
+
try {
|
|
402
|
+
localStorage.clear();
|
|
403
|
+
}
|
|
404
|
+
catch (e) { }
|
|
405
|
+
try {
|
|
406
|
+
sessionStorage.clear();
|
|
407
|
+
}
|
|
408
|
+
catch (e) { }
|
|
409
|
+
})
|
|
410
|
+
.catch(() => { });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (e) { }
|
|
414
|
+
}
|
|
415
|
+
if (!this.isSilent)
|
|
416
|
+
console.log(chalk_1.default.blue.bold(`\n[Execution]: Processing file: ${fileName}`));
|
|
417
|
+
this.emitLog("FILE_HEADER", "info", fileName, { filePath });
|
|
418
|
+
const features = new parser_1.Parser(new lexer_1.Lexer(content).tokenise()).parse();
|
|
419
|
+
const hasOnlyMode = features.some((f) => f.scenarios.some((s) => s.isOnly));
|
|
420
|
+
if (hasOnlyMode) {
|
|
421
|
+
if (!this.isSilent) {
|
|
422
|
+
console.log(chalk_1.default.yellow(` > [Debug Mode]: 'only.Scenario' detected. Operating in Ghost Mode.`));
|
|
423
|
+
}
|
|
424
|
+
features.forEach((f) => {
|
|
425
|
+
f.scenarios = f.scenarios.filter((s) => s.isOnly);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
for (const f of features) {
|
|
429
|
+
if (this.isAborted || this.isDebugHalted)
|
|
430
|
+
break;
|
|
431
|
+
if (f.scenarios.length === 0)
|
|
432
|
+
continue;
|
|
433
|
+
if (!this.isSilent)
|
|
434
|
+
console.log(chalk_1.default.magenta.bold(` Feature: ${f.name}`));
|
|
435
|
+
for (const s of f.scenarios) {
|
|
436
|
+
if (this.isAborted || this.isDebugHalted)
|
|
437
|
+
break;
|
|
438
|
+
if (!this.isSilent)
|
|
439
|
+
console.log(chalk_1.default.cyan(` Scenario: ${s.name}`));
|
|
440
|
+
this.broadcast("RUN_START", {
|
|
441
|
+
scenarioName: s.name,
|
|
442
|
+
fileName: this.currentFileName,
|
|
443
|
+
});
|
|
444
|
+
await this.runScenario(s, f.declarations);
|
|
445
|
+
if (this.isDebugHalted)
|
|
446
|
+
break;
|
|
447
|
+
await this.web
|
|
448
|
+
.getPage()
|
|
449
|
+
.waitForLoadState("load", { timeout: 4000 })
|
|
380
450
|
.catch(() => { });
|
|
381
|
-
await
|
|
382
|
-
.
|
|
383
|
-
|
|
384
|
-
localStorage.clear();
|
|
385
|
-
}
|
|
386
|
-
catch (e) { }
|
|
387
|
-
try {
|
|
388
|
-
sessionStorage.clear();
|
|
389
|
-
}
|
|
390
|
-
catch (e) { }
|
|
391
|
-
})
|
|
451
|
+
await this.web
|
|
452
|
+
.getPage()
|
|
453
|
+
.waitForLoadState("networkidle", { timeout: 4000 })
|
|
392
454
|
.catch(() => { });
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
393
456
|
}
|
|
394
457
|
}
|
|
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
458
|
if (this.isAborted)
|
|
403
459
|
break;
|
|
404
460
|
if (!this.isSilent)
|
|
405
|
-
console.log(chalk_1.default.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
461
|
+
console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
463
|
+
try {
|
|
464
|
+
const baseName = this.currentFileName.replace(".flow", "");
|
|
465
|
+
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
466
|
+
if (!fs.existsSync(mockCloudDir))
|
|
467
|
+
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
468
|
+
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
469
|
+
await this.web.shutdown(targetVideoPath);
|
|
409
470
|
if (!this.isSilent)
|
|
410
|
-
console.log(chalk_1.default.
|
|
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));
|
|
471
|
+
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
425
472
|
}
|
|
473
|
+
catch (err) { }
|
|
474
|
+
this.broadcast("FILE_COMPLETE", {
|
|
475
|
+
fileName: this.currentFileName,
|
|
476
|
+
duration: Date.now() - fileStartTime,
|
|
477
|
+
});
|
|
426
478
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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);
|
|
479
|
+
catch (e) {
|
|
480
|
+
if (this.isAborted)
|
|
481
|
+
break;
|
|
439
482
|
if (!this.isSilent)
|
|
440
|
-
console.
|
|
483
|
+
console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
|
|
484
|
+
try {
|
|
485
|
+
const baseName = this.currentFileName.replace(".flow", "");
|
|
486
|
+
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
487
|
+
if (!fs.existsSync(mockCloudDir))
|
|
488
|
+
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
489
|
+
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
490
|
+
await this.web.shutdown(targetVideoPath);
|
|
491
|
+
if (!this.isSilent)
|
|
492
|
+
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
493
|
+
}
|
|
494
|
+
catch (err) { }
|
|
495
|
+
this.broadcast("FILE_FAILED", {
|
|
496
|
+
fileName: this.currentFileName,
|
|
497
|
+
duration: Date.now() - fileStartTime,
|
|
498
|
+
error: e.message,
|
|
499
|
+
});
|
|
441
500
|
}
|
|
442
|
-
catch (err) { }
|
|
443
|
-
this.broadcast("FILE_COMPLETE", {
|
|
444
|
-
fileName: this.currentFileName,
|
|
445
|
-
duration: Date.now() - fileStartTime,
|
|
446
|
-
});
|
|
447
501
|
}
|
|
448
|
-
|
|
449
|
-
if (this.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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}`));
|
|
502
|
+
if (!this.isSilent && !this.isAborted) {
|
|
503
|
+
if (this.isDebugHalted) {
|
|
504
|
+
console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
console.log(chalk_1.default.green("\n[Execution]: All suites completed successfully."));
|
|
462
508
|
}
|
|
463
|
-
catch (err) { }
|
|
464
|
-
this.broadcast("FILE_FAILED", {
|
|
465
|
-
fileName: this.currentFileName,
|
|
466
|
-
duration: Date.now() - fileStartTime,
|
|
467
|
-
error: e.message,
|
|
468
|
-
});
|
|
469
509
|
}
|
|
470
510
|
}
|
|
471
|
-
|
|
472
|
-
|
|
511
|
+
finally {
|
|
512
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
513
|
+
}
|
|
473
514
|
}
|
|
474
515
|
async runScenario(scenario, globalDeclarations = []) {
|
|
475
516
|
for (const step of scenario.steps) {
|
|
@@ -529,6 +570,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
529
570
|
screenshot: stepContext.screenshot,
|
|
530
571
|
fileName: this.currentFileName,
|
|
531
572
|
});
|
|
573
|
+
if (step.isStop) {
|
|
574
|
+
if (!this.isSilent) {
|
|
575
|
+
console.log(chalk_1.default.cyan(` > [Execution]: Debug stop triggered. Halting global execution.`));
|
|
576
|
+
}
|
|
577
|
+
this.isDebugHalted = true;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
532
580
|
}
|
|
533
581
|
catch (e) {
|
|
534
582
|
if (this.isAborted)
|
|
@@ -561,6 +609,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
561
609
|
}
|
|
562
610
|
const isUIAction = [
|
|
563
611
|
"click",
|
|
612
|
+
"hover",
|
|
564
613
|
"type",
|
|
565
614
|
"forceClick",
|
|
566
615
|
"forceType",
|
|
@@ -639,11 +688,23 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
639
688
|
}
|
|
640
689
|
}
|
|
641
690
|
async executeStep(action, payload, localDeclarations = [], globalDeclarations = []) {
|
|
691
|
+
if (payload && payload.selector === "smart-burger-fallback") {
|
|
692
|
+
payload.selector = [
|
|
693
|
+
'button[aria-label*="menu" i]',
|
|
694
|
+
'[aria-label*="menu" i]',
|
|
695
|
+
'[title*="menu" i]',
|
|
696
|
+
".bm-burger-button",
|
|
697
|
+
'[class*="hamburger" i]',
|
|
698
|
+
'[class*="burger" i]',
|
|
699
|
+
'[id*="hamburger" i]',
|
|
700
|
+
'[id*="burger" i]',
|
|
701
|
+
].join(", ");
|
|
702
|
+
}
|
|
642
703
|
switch (action) {
|
|
643
704
|
case "extract":
|
|
644
705
|
this.processExtractions([
|
|
645
706
|
{
|
|
646
|
-
target: payload.target
|
|
707
|
+
target: payload.target,
|
|
647
708
|
jsonPath: payload.jsonPath,
|
|
648
709
|
variableName: payload.variableName,
|
|
649
710
|
isGlobal: payload.isGlobal,
|
|
@@ -791,6 +852,9 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
791
852
|
case "click":
|
|
792
853
|
await this.web.click(payload.selector, payload.elementType);
|
|
793
854
|
break;
|
|
855
|
+
case "hover":
|
|
856
|
+
await this.web.hover(payload.selector, payload.elementType);
|
|
857
|
+
break;
|
|
794
858
|
case "forceClick":
|
|
795
859
|
await this.web.forceClick(payload.selector, payload.elementType);
|
|
796
860
|
break;
|
|
@@ -841,23 +905,27 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
841
905
|
await this.waitForPipeline();
|
|
842
906
|
break;
|
|
843
907
|
case "expect":
|
|
844
|
-
|
|
908
|
+
const expectType = payload.type;
|
|
909
|
+
if (["status", "responseTime", "resBody", "resHeader", "cookie"].includes(expectType)) {
|
|
845
910
|
const apiState = this.api.getState();
|
|
846
|
-
if (
|
|
847
|
-
|
|
848
|
-
|
|
911
|
+
if (expectType === "status") {
|
|
912
|
+
const expectedStatus = payload.statusCode || payload.value;
|
|
913
|
+
if (String(apiState.status) !== String(expectedStatus))
|
|
914
|
+
throw new Error(`API Assertion Failed: Expected HTTP status ${expectedStatus}, but got ${apiState.status}`);
|
|
849
915
|
}
|
|
850
|
-
else if (
|
|
851
|
-
|
|
852
|
-
|
|
916
|
+
else if (expectType === "responseTime") {
|
|
917
|
+
const threshold = payload.threshold || payload.value;
|
|
918
|
+
if (apiState.responseTime > parseInt(threshold, 10))
|
|
919
|
+
throw new Error(`API Assertion Failed: Expected response time to be less than ${threshold}ms, but got ${apiState.responseTime}ms`);
|
|
853
920
|
}
|
|
854
921
|
else {
|
|
855
922
|
let actualValue;
|
|
856
|
-
if (
|
|
923
|
+
if (expectType === "resBody")
|
|
857
924
|
actualValue = this.extractFromJsonPath(apiState.body, payload.jsonPath);
|
|
858
|
-
else if (
|
|
859
|
-
actualValue =
|
|
860
|
-
|
|
925
|
+
else if (expectType === "resHeader")
|
|
926
|
+
actualValue =
|
|
927
|
+
apiState.headers?.[(payload.jsonPath || "").toLowerCase()];
|
|
928
|
+
else if (expectType === "cookie") {
|
|
861
929
|
const cookies = apiState.headers?.["set-cookie"];
|
|
862
930
|
if (Array.isArray(cookies)) {
|
|
863
931
|
const match = cookies.find((c) => c.startsWith(`${payload.jsonPath}=`));
|
|
@@ -865,7 +933,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
865
933
|
actualValue = match.split("=")[1].split(";")[0];
|
|
866
934
|
}
|
|
867
935
|
}
|
|
868
|
-
|
|
936
|
+
const assertionsArray = Array.isArray(payload.assertions)
|
|
937
|
+
? payload.assertions
|
|
938
|
+
: payload.condition
|
|
939
|
+
? [{ condition: payload.condition, value: payload.value }]
|
|
940
|
+
: [];
|
|
941
|
+
for (const assertion of assertionsArray) {
|
|
869
942
|
const condition = assertion.condition;
|
|
870
943
|
const rawExpected = assertion.value;
|
|
871
944
|
const expectedLower = String(rawExpected || "").toLowerCase();
|
|
@@ -1014,13 +1087,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1014
1087
|
}
|
|
1015
1088
|
}
|
|
1016
1089
|
}
|
|
1017
|
-
else if (
|
|
1090
|
+
else if (expectType === "visible")
|
|
1018
1091
|
await this.web.expectVisible(payload.selector, payload.elementType);
|
|
1019
|
-
else if (
|
|
1092
|
+
else if (expectType === "text")
|
|
1020
1093
|
await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
|
|
1021
|
-
else if (
|
|
1094
|
+
else if (expectType === "value")
|
|
1022
1095
|
await this.web.expectValue(payload.selector, payload.text, payload.elementType);
|
|
1023
|
-
else if (
|
|
1096
|
+
else if (expectType === "transcript")
|
|
1024
1097
|
await this.web.expectTranscript(payload.text);
|
|
1025
1098
|
break;
|
|
1026
1099
|
}
|