flowstride 1.0.10 → 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/README.md CHANGED
@@ -35,7 +35,19 @@ npx flowstride init
35
35
 
36
36
  _(This creates the default directory structure (`/flows`, `/reports`, `/plugins`) and generates a `example.flow` sample file.)_
37
37
 
38
- **3. Run your first test:**
38
+ **3. Install VS Code Extension (Highly Recommended):**
39
+
40
+ To get the best developer experience, including syntax highlighting, auto-completion, and native IDE support for .flow files, install our official VS Code extension directly via the VS Code CLI.
41
+
42
+ Run the command below:
43
+
44
+ ```bash
45
+ code --install-extension flowstride.flowstride-vscode
46
+ ```
47
+
48
+ _Alternatively, you can open the Extensions tab in VS Code and search for Flowstride Automation_
49
+
50
+ **4. Run your first test:**
39
51
 
40
52
  ```bash
41
53
  npx flowstride run
@@ -47,7 +59,7 @@ npx flowstride run flows/example.flow
47
59
 
48
60
  _(This command executes your test while instantly booting up the local Flowstride Dashboard.)_
49
61
 
50
- **4. Login to Flowstride Cloud:**
62
+ **5. Login to Flowstride Cloud:**
51
63
 
52
64
  Authenticate your local project with your Cloud account to unlock Cloud features after signing up at [cloud.flowstride.io](https://cloud.flowstride.io).
53
65
 
@@ -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
@@ -45,6 +45,7 @@ const fs = __importStar(require("fs"));
45
45
  const path = __importStar(require("path"));
46
46
  const os = __importStar(require("os"));
47
47
  const tar = __importStar(require("tar"));
48
+ const child_process_1 = require("child_process");
48
49
  const prompts_1 = require("@inquirer/prompts");
49
50
  const chalk_1 = __importDefault(require("chalk"));
50
51
  const ora_1 = __importDefault(require("ora"));
@@ -53,7 +54,7 @@ const API_BASE = process.env.FLOWSTRIDE_API_URL || "https://cloud.flowstride.io"
53
54
  program
54
55
  .name("flowstride")
55
56
  .description("Flowstride: The Enterprise Flow-First Automation CLI")
56
- .version("1.0.10");
57
+ .version("1.0.12");
57
58
  program
58
59
  .command("init")
59
60
  .description("Initialise a new Flowstride workspace in the current directory")
@@ -154,6 +155,11 @@ program
154
155
  cwd: nodeModulesPath,
155
156
  strip: 1,
156
157
  });
158
+ installSpinner.text = "Wiring up Enterprise dependencies...";
159
+ (0, child_process_1.execSync)("npm install --omit=dev", {
160
+ cwd: nodeModulesPath,
161
+ stdio: "ignore",
162
+ });
157
163
  installSpinner.succeed(chalk_1.default.green("Enterprise Engine installed successfully!"));
158
164
  }
159
165
  catch (installError) {
@@ -317,7 +323,6 @@ program
317
323
  console.error(`Flowstride requires all UI tests to be inside a valid directory.`);
318
324
  process.exit(1);
319
325
  }
320
- // Inject our DRM state into options before passing to runner
321
326
  options.forceOS = forceOS;
322
327
  await (0, run_1.runAction)(strictUiFlowsDir, options);
323
328
  });
@@ -325,7 +330,7 @@ async function handleOfflinePrompt() {
325
330
  console.log(chalk_1.default.yellow("🛜 OFFLINE: Unable to reach Flowstride Cloud. Enterprise Sync paused."));
326
331
  if (process.env.CI || process.env.FLOWSTRIDE_API_KEY) {
327
332
  console.log(chalk_1.default.yellow("⚠️ CI/CD Environment: Automatically falling back to OS Engine."));
328
- return true; // Force OS
333
+ return true;
329
334
  }
330
335
  const runAnyway = await (0, prompts_1.confirm)({
331
336
  message: "⚠️ Execute a local OS run anyway? (Pro features are disabled on OS)",
@@ -334,7 +339,7 @@ async function handleOfflinePrompt() {
334
339
  if (!runAnyway) {
335
340
  process.exit(0);
336
341
  }
337
- return true; // User said yes, Force OS
342
+ return true;
338
343
  }
339
344
  program
340
345
  .command("report")
@@ -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" || lowerIdent === "scenario") {
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 (["given", "when", "then", "and"].includes(lowerIdent)) {
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
- "responsetime",
294
- "reqbody",
295
- "resbody",
296
- "reqheader",
297
- "resheader",
306
+ "responseTime",
307
+ "reqBody",
308
+ "resBody",
309
+ "reqHeader",
310
+ "resHeader",
298
311
  "cookie",
299
312
  "global",
300
- ].includes(lowerIdent)) {
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,
@@ -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;
@@ -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
- const blockType = blockToken.value.toLowerCase();
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
- ? `${blockToken.value} "${description}"`
121
- : blockToken.value;
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
- return { name: nameToken.value, declarations, steps };
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: `S${this.stepCounter++}`,
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.toLowerCase();
269
- let normalizedTarget = extTarget;
270
- if (extTarget === "resbody")
271
- normalizedTarget = "resBody";
272
- else if (extTarget === "reqbody")
273
- normalizedTarget = "reqBody";
274
- else if (extTarget === "resheader")
275
- normalizedTarget = "resHeader";
276
- else if (extTarget === "reqheader")
277
- normalizedTarget = "reqHeader";
278
- if (!["resBody", "resHeader", "cookie"].includes(normalizedTarget)) {
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 = normalizedTarget;
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
- payload.selector = this.parseSmartSelectorPayload();
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
- let mod = modToken.value.toLowerCase();
430
- if (mod === "resbody")
431
- mod = "resBody";
432
- if (mod === "resheader")
433
- mod = "resHeader";
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 === "responsetime") {
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
- payload.selector = this.parseSmartSelectorPayload();
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: `S${this.stepCounter++}`,
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.toLowerCase();
541
- if (target === "reqheader") {
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 === "reqbody") {
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;
@@ -21,6 +21,7 @@ export declare class FlowWorker extends EventEmitter {
21
21
  private pluginManager?;
22
22
  private isSilent;
23
23
  private isAborted;
24
+ private isDebugHalted;
24
25
  private currentStepContext;
25
26
  private currentFileName;
26
27
  private activeAudioPath;
@@ -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 === "resbody") {
192
+ if (ext.target === "resBody") {
190
193
  val = this.extractFromJsonPath(state.body, ext.jsonPath);
191
194
  }
192
- else if (ext.target === "resheader") {
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
- 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;
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
- for (const filePath of filesToProcess) {
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
- 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();
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 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()
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 page
382
- .evaluate(() => {
383
- try {
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.magenta.bold(` Feature: ${f.name}`));
406
- for (const s of f.scenarios) {
407
- if (this.isAborted)
408
- break;
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.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));
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
- 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);
479
+ catch (e) {
480
+ if (this.isAborted)
481
+ break;
439
482
  if (!this.isSilent)
440
- console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
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
- 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}`));
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
- if (!this.isSilent && !this.isAborted)
472
- console.log(chalk_1.default.green("[Execution]: All suites completed successfully."));
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.toLowerCase(),
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
- if (["status", "responsetime", "resbody", "resheader", "cookie"].includes(payload.type)) {
908
+ const expectType = payload.type;
909
+ if (["status", "responseTime", "resBody", "resHeader", "cookie"].includes(expectType)) {
845
910
  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}`);
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 (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`);
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 (payload.type === "resbody")
923
+ if (expectType === "resBody")
857
924
  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") {
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
- for (const assertion of payload.assertions) {
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 (payload.type === "visible")
1090
+ else if (expectType === "visible")
1018
1091
  await this.web.expectVisible(payload.selector, payload.elementType);
1019
- else if (payload.type === "text")
1092
+ else if (expectType === "text")
1020
1093
  await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
1021
- else if (payload.type === "value")
1094
+ else if (expectType === "value")
1022
1095
  await this.web.expectValue(payload.selector, payload.text, payload.elementType);
1023
- else if (payload.type === "transcript")
1096
+ else if (expectType === "transcript")
1024
1097
  await this.web.expectTranscript(payload.text);
1025
1098
  break;
1026
1099
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowstride",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "An opensource flowfirst test automation framework",
5
5
  "workspaces": [
6
6
  "packages/flowstride_report"