flowstride 1.0.11 → 1.0.13

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.
@@ -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.11");
57
+ .version("1.0.13");
58
58
  program
59
59
  .command("init")
60
60
  .description("Initialise a new Flowstride workspace in the current directory")
@@ -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;