flowstride 1.0.20 → 1.0.22

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.
Files changed (99) hide show
  1. package/NATIVE_BROWSER.md +381 -0
  2. package/NATIVE_CI.md +17 -5
  3. package/NATIVE_WORKFLOWS.md +44 -0
  4. package/README.md +11 -4
  5. package/dist/adapters/api/undici.d.ts +11 -1
  6. package/dist/adapters/api/undici.js +28 -3
  7. package/dist/adapters/web/playwright.d.ts +22 -2
  8. package/dist/adapters/web/playwright.js +254 -107
  9. package/dist/ci/config.js +14 -1
  10. package/dist/ci/distribution.d.ts +38 -0
  11. package/dist/ci/distribution.js +134 -0
  12. package/dist/ci/init.d.ts +4 -1
  13. package/dist/ci/init.js +13 -2
  14. package/dist/ci/runtime.js +33 -6
  15. package/dist/cli/commands/run.js +17 -4
  16. package/dist/cli/index.js +24 -5
  17. package/dist/cli/manual_server/workspace_server.js +90 -74
  18. package/dist/manual/security.d.ts +6 -6
  19. package/dist/native/accessibility.d.ts +4 -0
  20. package/dist/native/accessibility.js +84 -0
  21. package/dist/native/assertions.d.ts +3 -0
  22. package/dist/native/assertions.js +223 -0
  23. package/dist/native/compatibility.d.ts +3 -0
  24. package/dist/native/compatibility.js +16 -0
  25. package/dist/native/components.d.ts +12 -0
  26. package/dist/native/components.js +122 -0
  27. package/dist/native/dashboard.d.ts +7 -0
  28. package/dist/native/dashboard.js +32 -0
  29. package/dist/native/deadline.d.ts +2 -0
  30. package/dist/native/deadline.js +13 -0
  31. package/dist/native/events.d.ts +35 -0
  32. package/dist/native/events.js +308 -0
  33. package/dist/native/evidence.d.ts +3 -0
  34. package/dist/native/evidence.js +31 -0
  35. package/dist/native/functions.d.ts +10 -0
  36. package/dist/native/functions.js +131 -0
  37. package/dist/native/runtime.d.ts +17 -0
  38. package/dist/native/runtime.js +160 -0
  39. package/dist/native/validation.d.ts +107 -0
  40. package/dist/native/validation.js +84 -0
  41. package/dist/parser/assertion_contract.d.ts +12 -0
  42. package/dist/parser/assertion_contract.js +56 -0
  43. package/dist/parser/lexer.js +15 -4
  44. package/dist/parser/parser.js +15 -7
  45. package/dist/reporting/assertion_result.d.ts +36 -0
  46. package/dist/reporting/assertion_result.js +104 -0
  47. package/dist/reporting/pdf_generator.js +25 -20
  48. package/dist/reporting/runtime_failure.d.ts +10 -0
  49. package/dist/reporting/runtime_failure.js +64 -0
  50. package/dist/reporting/step_status.d.ts +10 -0
  51. package/dist/reporting/step_status.js +44 -0
  52. package/dist/runner/flow_orchestrator.d.ts +5 -0
  53. package/dist/runner/flow_orchestrator.js +30 -8
  54. package/dist/runner/flow_worker.d.ts +15 -1
  55. package/dist/runner/flow_worker.js +219 -42
  56. package/dist/runner/plugin_manager.js +3 -0
  57. package/dist/runner/temporary_store.d.ts +1 -0
  58. package/dist/runner/temporary_store.js +3 -0
  59. package/dist/sdk/api_proxy.d.ts +3 -0
  60. package/dist/sdk/api_proxy.js +16 -0
  61. package/dist/security/localWorkspace.d.ts +2 -0
  62. package/dist/security/localWorkspace.js +17 -0
  63. package/dist/security/redaction.js +1 -1
  64. package/dist/types/index.d.ts +13 -0
  65. package/examples/native-workflows/README.md +144 -0
  66. package/examples/native-workflows/api-and-ui/README.md +80 -0
  67. package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
  68. package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
  69. package/examples/native-workflows/api-ui-otp/README.md +140 -0
  70. package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
  71. package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
  72. package/examples/native-workflows/audio-to-text/README.md +131 -0
  73. package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
  74. package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
  75. package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
  76. package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
  77. package/examples/native-workflows/iframes/README.md +116 -0
  78. package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
  79. package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
  80. package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
  81. package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
  82. package/examples/native-workflows/manual-testing/README.md +85 -0
  83. package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
  84. package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
  85. package/examples/native-workflows/shared/server.mjs +80 -0
  86. package/examples/native-workflows/tables-and-cells/README.md +118 -0
  87. package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
  88. package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
  89. package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
  90. package/examples/native-workflows/variables-and-reuse/README.md +140 -0
  91. package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
  92. package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
  93. package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
  94. package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
  95. package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
  96. package/package.json +23 -9
  97. package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
  98. package/src/ui/dist/index.html +1 -1
  99. package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
@@ -37,7 +37,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FlowWorker = void 0;
40
- const deadline_1 = require("../ci/deadline");
40
+ const runtime_failure_1 = require("../reporting/runtime_failure");
41
+ const assertion_result_1 = require("../reporting/assertion_result");
42
+ const assertion_contract_1 = require("../parser/assertion_contract");
43
+ const deadline_1 = require("../native/deadline");
44
+ const runtime_1 = require("../native/runtime");
45
+ const deadline_2 = require("../ci/deadline");
41
46
  const replayRequest_1 = require("../utils/replayRequest");
42
47
  const projectPaths_1 = require("../utils/projectPaths");
43
48
  const redaction_1 = require("../security/redaction");
@@ -57,10 +62,20 @@ class FlowWorker extends events_1.EventEmitter {
57
62
  store;
58
63
  sessions;
59
64
  pluginManager;
65
+ static nativeBrowserProtocol = 2;
66
+ static nativeAssertionProtocol = 1;
60
67
  isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
61
68
  isAborted = false;
62
69
  bailTriggered = false;
63
70
  isDebugHalted = false;
71
+ native;
72
+ currentAssertion;
73
+ commandSecretValues = [];
74
+ batchPassed = true;
75
+ attempt = 1;
76
+ maxAttempts = 1;
77
+ fallbackStream;
78
+ interceptedContexts = new WeakSet();
64
79
  currentStepContext = "Initializing...";
65
80
  currentFileName = "System";
66
81
  activeAudioPath = undefined;
@@ -79,10 +94,26 @@ class FlowWorker extends events_1.EventEmitter {
79
94
  this.store = store;
80
95
  this.sessions = sessions;
81
96
  this.pluginManager = pluginManager;
97
+ this.native = new runtime_1.NativeRuntime(web, config);
98
+ this.web.onPageChanged = async () => { await this.startStreaming(); await this.setupNetworkInterception(); };
82
99
  }
83
100
  broadcast(type, payload) {
84
- const secrets = [this.config.machineToken || '', ...(this.dataGen?.sensitiveValues?.() || []), ...Object.entries(process.env).filter(([key]) => /password|token|secret|api.?key/i.test(key)).map(([, value]) => String(value)), ...Object.entries(this.localVariables).filter(([key]) => /password|passcode|token|secret|otp|api.?key/i.test(key)).map(([, value]) => String(value))];
85
- this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
101
+ if (["STEP_ADD", "STEP_UPDATE", "LOG_EVENT"].includes(type))
102
+ payload = { ...payload, attempt: this.attempt, profile: this.web.activeProfile || "default" };
103
+ this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, this.secretValues(), this.config.ci === false) });
104
+ }
105
+ secretValues() {
106
+ return [...this.commandSecretValues, this.config.machineToken || '', ...(this.dataGen?.sensitiveValues?.() || []), ...Object.entries(process.env).filter(([key]) => /password|token|secret|api.?key/i.test(key)).map(([, value]) => String(value)), ...Object.entries(this.localVariables).filter(([key]) => /password|passcode|token|secret|otp|api.?key/i.test(key)).map(([, value]) => String(value))];
107
+ }
108
+ assertionForStep(step) {
109
+ if (!this.currentAssertion)
110
+ return;
111
+ const value = { ...this.currentAssertion, sourceFile: this.currentFileName, sourceLine: step.line, profile: this.web.activeProfile || 'default' };
112
+ return (0, redaction_1.redactWorkerPayload)('STEP_UPDATE', { assertion: value }, this.secretValues()).assertion;
113
+ }
114
+ printAssertion(value) {
115
+ if (!this.isSilent && value)
116
+ console.log((0, assertion_result_1.assertionText)(value).split('\n').map(line => ' ' + line).join('\n'));
86
117
  }
87
118
  emitLog(type, status, summary, details, customId, overrideFileName) {
88
119
  const id = customId || Math.random().toString(36).substr(2, 9);
@@ -99,24 +130,16 @@ class FlowWorker extends events_1.EventEmitter {
99
130
  return id;
100
131
  }
101
132
  translateError(error, action, payload) {
102
- const msg = error.message || String(error);
103
- if (msg.includes("API Assertion Failed") ||
104
- msg.includes("Network Execution Error") ||
105
- msg.includes("Extraction Failed") ||
106
- msg.includes("Plugin Execution Failed") ||
107
- msg.includes("Scoping Error"))
108
- return msg;
109
- const target = payload.selector || payload.text || payload.url || "the element";
110
- if (msg.includes("Timeout"))
111
- return `I waited for "${target}" to appear, but it never did.`;
112
- if (msg.includes("intercepted"))
113
- return `I found "${target}", but another element is blocking it.`;
114
- if (msg.includes("Framework rejected"))
115
- return msg;
116
- return `An error occurred while trying to ${action}: ${msg.split("\n")[0]}`;
133
+ return (0, runtime_failure_1.runtimeFailure)(action, payload, error, { file: this.currentFileName, profile: this.web.activeProfile || 'default', timeout: this.config.timeout });
117
134
  }
118
135
  async captureStepContext(payload) {
119
- const page = this.web.getPage();
136
+ let page;
137
+ try {
138
+ page = this.web.getPage();
139
+ }
140
+ catch {
141
+ return {};
142
+ }
120
143
  const context = {};
121
144
  try {
122
145
  const buffer = await page.screenshot({ type: "jpeg", quality: 50 });
@@ -339,6 +362,7 @@ class FlowWorker extends events_1.EventEmitter {
339
362
  this.isAborted = true;
340
363
  }
341
364
  async runBatch(filesToProcess) {
365
+ this.batchPassed = filesToProcess.length > 0;
342
366
  const isCliHeadless = process.argv.includes("--headless");
343
367
  if (isCliHeadless) {
344
368
  this.config.headless = true;
@@ -365,7 +389,7 @@ class FlowWorker extends events_1.EventEmitter {
365
389
  }
366
390
  catch (e) { }
367
391
  if (!this.config.ci)
368
- process.exit(0);
392
+ process.exit(this.isAborted ? 130 : process.exitCode || 0);
369
393
  };
370
394
  process.once("SIGINT", sigintHandler);
371
395
  try {
@@ -394,7 +418,13 @@ class FlowWorker extends events_1.EventEmitter {
394
418
  this.localBrowserSessions = {};
395
419
  this.api.resetState();
396
420
  this.dataGen = new data_generators_1.DataGenerators();
421
+ await this.native.dispose();
397
422
  await this.web.initialise(this.activeAudioPath);
423
+ if (typeof this.web.getBrowserInfo === 'function') {
424
+ const browser = this.web.getBrowserInfo();
425
+ console.log(`[Browser]: ${browser.engine} ${browser.version}; ${browser.headless ? 'headless' : 'headed'}; ${fileName}`);
426
+ this.broadcast('BROWSER_INFO', { ...browser, fileName });
427
+ }
398
428
  await this.startStreaming();
399
429
  await this.setupNetworkInterception();
400
430
  const content = fs.readFileSync(filePath, "utf8");
@@ -453,7 +483,8 @@ class FlowWorker extends events_1.EventEmitter {
453
483
  scenarioName: s.name,
454
484
  fileName: this.currentFileName,
455
485
  });
456
- await this.runScenario(s, f.declarations);
486
+ if (!await this.runScenarioWithRetries(s, f.declarations))
487
+ this.batchPassed = false;
457
488
  if (this.isDebugHalted || this.bailTriggered)
458
489
  break;
459
490
  await this.web
@@ -482,6 +513,7 @@ class FlowWorker extends events_1.EventEmitter {
482
513
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
483
514
  for (const message of this.web.pageErrors || [])
484
515
  this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
516
+ await this.native.dispose();
485
517
  await this.web.shutdown(targetVideoPath);
486
518
  if (!this.isSilent)
487
519
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -493,10 +525,11 @@ class FlowWorker extends events_1.EventEmitter {
493
525
  });
494
526
  }
495
527
  catch (e) {
528
+ this.batchPassed = false;
496
529
  if (this.isAborted)
497
530
  break;
498
531
  if (!this.isSilent)
499
- console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
532
+ console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${(0, redaction_1.redactText)((0, runtime_failure_1.runtimeFailure)("file.setup", { file: this.currentFileName }, e, { file: this.currentFileName }), this.secretValues())}`));
500
533
  try {
501
534
  const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
502
535
  const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
@@ -505,6 +538,7 @@ class FlowWorker extends events_1.EventEmitter {
505
538
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
506
539
  for (const message of this.web.pageErrors || [])
507
540
  this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
541
+ await this.native.dispose();
508
542
  await this.web.shutdown(targetVideoPath);
509
543
  if (!this.isSilent)
510
544
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -513,7 +547,7 @@ class FlowWorker extends events_1.EventEmitter {
513
547
  this.broadcast("FILE_FAILED", {
514
548
  fileName: this.currentFileName,
515
549
  duration: Date.now() - fileStartTime,
516
- error: e.message,
550
+ error: (0, runtime_failure_1.runtimeFailure)("file.setup", { file: this.currentFileName }, e, { file: this.currentFileName }),
517
551
  });
518
552
  }
519
553
  }
@@ -522,7 +556,7 @@ class FlowWorker extends events_1.EventEmitter {
522
556
  console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
523
557
  }
524
558
  else {
525
- console.log(chalk_1.default.green("\n[Execution]: All suites completed successfully."));
559
+ console.log(this.batchPassed ? chalk_1.default.green("\n[Execution]: All suites completed successfully.") : chalk_1.default.red("\n[Execution]: Test suite failed. Review the diagnostics above."));
526
560
  }
527
561
  }
528
562
  }
@@ -531,6 +565,7 @@ class FlowWorker extends events_1.EventEmitter {
531
565
  try {
532
566
  const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
533
567
  fs.mkdirSync(directory, { recursive: true });
568
+ await this.native.dispose();
534
569
  await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
535
570
  }
536
571
  catch (error) {
@@ -539,25 +574,90 @@ class FlowWorker extends events_1.EventEmitter {
539
574
  }
540
575
  process.removeListener("SIGINT", sigintHandler);
541
576
  }
577
+ return this.batchPassed && !this.isAborted && !this.isDebugHalted;
578
+ }
579
+ async runScenarioWithRetries(scenario, declarations) {
580
+ this.maxAttempts = (this.config.retries || 0) + 1;
581
+ const baseline = this.maxAttempts > 1 ? await this.web.checkpointProfiles() : undefined;
582
+ const variables = this.maxAttempts > 1 ? structuredClone(this.localVariables) : {}, sessions = this.maxAttempts > 1 ? structuredClone(this.localBrowserSessions) : {};
583
+ const globals = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalVariables) : {}, globalSessions = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalBrowserSessions) : {};
584
+ const storeState = this.maxAttempts > 1 ? structuredClone(this.store.getAll?.()) : undefined;
585
+ const apiState = this.maxAttempts > 1 ? this.api.checkpointState?.() : undefined;
586
+ let finalPassed = false;
587
+ for (this.attempt = 1; this.attempt <= this.maxAttempts; this.attempt++) {
588
+ if (this.isAborted || this.isDebugHalted)
589
+ break;
590
+ if (this.attempt > 1) {
591
+ await this.native.dispose();
592
+ if (apiState)
593
+ this.api.restoreState(apiState);
594
+ else
595
+ this.api.resetState();
596
+ this.localVariables = structuredClone(variables);
597
+ this.localBrowserSessions = structuredClone(sessions);
598
+ FlowWorker.globalVariables = structuredClone(globals);
599
+ FlowWorker.globalBrowserSessions = structuredClone(globalSessions);
600
+ if (storeState) {
601
+ this.store.clear();
602
+ for (const [key, value] of Object.entries(structuredClone(storeState)))
603
+ this.store.set(key, value);
604
+ }
605
+ await this.web.restoreProfiles(baseline);
606
+ }
607
+ const started = Date.now();
608
+ this.broadcast('SCENARIO_ATTEMPT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt });
609
+ const errorIndex = this.web.pageErrors?.length || 0;
610
+ const completed = await this.runScenario(structuredClone(scenario), declarations);
611
+ const browserErrors = this.web.pageErrors?.slice(errorIndex) || [];
612
+ const passed = completed && (this.config.browserErrors === 'record' || !browserErrors.length);
613
+ if (this.config.browserErrors !== 'record')
614
+ for (const message of browserErrors) {
615
+ const error = (0, runtime_failure_1.runtimeFailure)('browser.pageError', {}, new Error(message), { file: this.currentFileName, profile: this.web.activeProfile || 'default' });
616
+ this.emitLog('ERROR', 'error', error, { rawError: error, command: 'browser.pageError' }, undefined, this.currentFileName);
617
+ if (!this.isSilent)
618
+ console.error((0, redaction_1.redactText)(error, this.secretValues()));
619
+ }
620
+ this.broadcast('SCENARIO_RESULT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt, status: this.isAborted ? 'interrupted' : passed ? 'passed' : 'failed', flaky: passed && this.attempt > 1, browserErrors, duration: Date.now() - started });
621
+ finalPassed = passed && !(this.config.failOnFlaky && this.attempt > 1);
622
+ if (passed || this.isAborted || this.isDebugHalted)
623
+ break;
624
+ if (this.attempt < this.maxAttempts)
625
+ this.web.pageErrors?.splice(errorIndex);
626
+ }
627
+ this.attempt = 1;
628
+ this.maxAttempts = 1;
629
+ return finalPassed;
542
630
  }
543
631
  async runScenario(scenario, globalDeclarations = []) {
544
632
  let hasScenarioFailed = false;
545
- for (const step of scenario.steps) {
633
+ for (const originalStep of scenario.steps) {
634
+ const step = structuredClone(originalStep);
635
+ this.currentAssertion = undefined;
636
+ this.commandSecretValues = [];
546
637
  if (this.isAborted)
547
638
  break;
548
639
  const blockName = step.blockDescription.split(" ")[0];
549
640
  let blockDesc = step.blockDescription.substring(blockName.length).trim();
550
- blockDesc = this.resolveTextVariables(blockDesc);
551
- if (this.dataGen)
552
- blockDesc = this.dataGen.resolve(blockDesc);
553
- step.payload = this.resolveTemplateData(step.payload);
554
- this.currentStepContext = `${blockName} ${blockDesc}`;
555
- const isOpt = step.payload?.optional;
641
+ const stepLabel = (0, assertion_result_1.commandLabel)(step.action, step.payload);
556
642
  if (hasScenarioFailed) {
557
- this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, status: 'not_tested', fileName: this.currentFileName });
558
- this.broadcast('STEP_UPDATE', { id: step.id, status: 'not_tested', fileName: this.currentFileName });
643
+ const assertion = (0, assertion_result_1.unexecutedAssertion)(step.action, step.payload);
644
+ this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, commandLabel: stepLabel, sourceLine: step.line, status: 'not_tested', fileName: this.currentFileName });
645
+ this.broadcast('STEP_UPDATE', { id: step.id, status: 'not_tested', assertion, error: null, commandLabel: stepLabel, sourceLine: step.line, fileName: this.currentFileName });
559
646
  continue;
560
647
  }
648
+ let resolutionError;
649
+ try {
650
+ blockDesc = this.resolveTextVariables(blockDesc);
651
+ if (this.dataGen)
652
+ blockDesc = this.dataGen.resolve(blockDesc);
653
+ step.payload = this.resolveTemplateData(step.payload);
654
+ }
655
+ catch (error) {
656
+ resolutionError = error;
657
+ }
658
+ this.commandSecretValues = (0, runtime_failure_1.commandSecrets)(step.action, step.payload);
659
+ this.currentStepContext = `${blockName} ${blockDesc}`;
660
+ const isOpt = step.payload?.optional;
561
661
  const mockSpinner = {
562
662
  text: "",
563
663
  start() {
@@ -584,6 +684,8 @@ class FlowWorker extends events_1.EventEmitter {
584
684
  }).start();
585
685
  this.emitLog("ACTION", "info", `${blockName}: ${blockDesc}`, {
586
686
  command: step.action,
687
+ commandLabel: stepLabel,
688
+ sourceLine: step.line,
587
689
  payload: step.payload,
588
690
  }, undefined, this.currentFileName);
589
691
  this.broadcast("STEP_ADD", {
@@ -591,17 +693,29 @@ class FlowWorker extends events_1.EventEmitter {
591
693
  type: blockName,
592
694
  description: blockDesc,
593
695
  command: step.action,
696
+ commandLabel: stepLabel,
697
+ sourceLine: step.line,
594
698
  status: "running",
699
+ error: null, assertion: null,
595
700
  fileName: this.currentFileName,
596
701
  });
702
+ const stepStarted = Date.now();
597
703
  try {
704
+ if (resolutionError)
705
+ throw resolutionError;
598
706
  await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
599
707
  const stepContext = await this.captureStepContext(step.payload);
708
+ const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
600
709
  spinner.succeed();
710
+ const assertion = this.assertionForStep(step);
711
+ this.printAssertion(assertion);
601
712
  this.broadcast("STEP_UPDATE", {
602
713
  id: step.id,
603
714
  status: "passed",
715
+ error: null,
716
+ assertion,
604
717
  screenshot: stepContext.screenshot,
718
+ domSnapshot,
605
719
  fileName: this.currentFileName,
606
720
  });
607
721
  if (step.isStop) {
@@ -615,7 +729,9 @@ class FlowWorker extends events_1.EventEmitter {
615
729
  catch (e) {
616
730
  if (this.isAborted)
617
731
  break;
732
+ const assertion = this.assertionForStep(step);
618
733
  const stepContext = await this.captureStepContext(step.payload);
734
+ const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
619
735
  let strippedDom = "";
620
736
  try {
621
737
  const page = this.web.getPage();
@@ -630,13 +746,16 @@ class FlowWorker extends events_1.EventEmitter {
630
746
  if (isOpt) {
631
747
  spinner.warn(chalk_1.default.yellow(`[Skipped]: ${blockDesc}`));
632
748
  this.emitLog("ACTION", "info", `Skipped Optional Step: ${blockDesc}`, {
633
- reason: "Element not present after full timeout.",
749
+ reason: (0, runtime_failure_1.runtimeFailure)(stepLabel, step.payload, e, { file: this.currentFileName, line: step.line }),
634
750
  strippedDom,
635
751
  }, undefined, this.currentFileName);
636
752
  this.broadcast("STEP_UPDATE", {
637
753
  id: step.id,
638
754
  status: "skipped",
755
+ error: (0, runtime_failure_1.runtimeFailure)(stepLabel, step.payload, e, { file: this.currentFileName, line: step.line }),
756
+ assertion,
639
757
  screenshot: stepContext.screenshot,
758
+ domSnapshot,
640
759
  fileName: this.currentFileName,
641
760
  });
642
761
  continue;
@@ -672,10 +791,17 @@ class FlowWorker extends events_1.EventEmitter {
672
791
  else {
673
792
  spinner.fail();
674
793
  }
675
- const humanReadableError = this.translateError(e, step.action, step.payload);
794
+ const humanReadableError = assertion ? (0, assertion_result_1.assertionText)(assertion) : (0, runtime_failure_1.runtimeFailure)(stepLabel, step.payload, e, { file: this.currentFileName, line: step.line, profile: this.web.activeProfile || "default", timeout: this.config.timeout, elapsed: Date.now() - stepStarted });
795
+ this.printAssertion(assertion);
796
+ if (!this.isSilent && !assertion)
797
+ console.error(' ' + (0, redaction_1.redactText)(String(humanReadableError), this.secretValues()).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''));
676
798
  this.emitLog("ERROR", "error", humanReadableError, {
677
- rawError: e.message,
799
+ rawError: humanReadableError,
800
+ stepId: step.id,
801
+ assertion,
678
802
  command: step.action,
803
+ commandLabel: stepLabel,
804
+ sourceLine: step.line,
679
805
  screenshot: stepContext.screenshot,
680
806
  boundingBox: stepContext.boundingBox,
681
807
  strippedDom,
@@ -683,16 +809,20 @@ class FlowWorker extends events_1.EventEmitter {
683
809
  this.broadcast("STEP_UPDATE", {
684
810
  id: step.id,
685
811
  status: "failed",
812
+ error: humanReadableError,
813
+ assertion,
686
814
  screenshot: stepContext.screenshot,
815
+ domSnapshot,
687
816
  fileName: this.currentFileName,
688
817
  });
689
818
  hasScenarioFailed = true;
690
- if (this.config.bail && !this.bailTriggered) {
819
+ if (this.config.bail && this.attempt >= this.maxAttempts && !this.bailTriggered) {
691
820
  this.bailTriggered = true;
692
821
  this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
693
822
  }
694
823
  }
695
824
  }
825
+ return !hasScenarioFailed;
696
826
  }
697
827
  async waitForPipeline() {
698
828
  if (!this.activeAudioPath || !fs.existsSync(this.activeAudioPath)) {
@@ -738,7 +868,29 @@ class FlowWorker extends events_1.EventEmitter {
738
868
  '[id*="burger" i]',
739
869
  ].join(", ");
740
870
  }
871
+ if (action === 'expect')
872
+ (0, assertion_contract_1.validateExpectationType)(payload?.type);
741
873
  switch (action) {
874
+ case 'native': {
875
+ const timeout = (0, deadline_1.nativeStepTimeout)(payload.pluginMethod, payload.data, this.config.timeout);
876
+ let result;
877
+ try {
878
+ result = await (0, deadline_2.withDeadline)(this.native.execute(payload.pluginMethod, payload.data), timeout);
879
+ this.currentAssertion = this.native.lastAssertion;
880
+ }
881
+ catch (error) {
882
+ const fallback = (0, assertion_result_1.unexecutedAssertion)('native', payload, 'The assertion could not execute.');
883
+ if (fallback) {
884
+ fallback.status = 'failed';
885
+ fallback.checks.push({ key: 'execution', label: 'Assertion execution', status: 'failed', reason: String(error.message || error) });
886
+ }
887
+ this.currentAssertion = (0, assertion_result_1.failedAssertion)(error.assertion || this.native.lastAssertion || fallback, String(error.message || error));
888
+ throw error;
889
+ }
890
+ if (payload.saveAs)
891
+ this.localVariables[payload.saveAs] = result;
892
+ break;
893
+ }
742
894
  case "extract":
743
895
  if (["resBody", "resHeader", "cookie"].includes(payload.target)) {
744
896
  this.processExtractions([
@@ -974,8 +1126,8 @@ class FlowWorker extends events_1.EventEmitter {
974
1126
  throw new Error(`API Assertion Failed: Expected HTTP status ${expectedStatus}, but got ${apiState.status}`);
975
1127
  }
976
1128
  else if (expectType === "responseTime") {
977
- const threshold = payload.threshold || payload.value;
978
- if (apiState.responseTime > parseInt(threshold, 10))
1129
+ const threshold = (0, assertion_contract_1.validateResponseTimeThreshold)(payload.threshold ?? payload.value);
1130
+ if (apiState.responseTime > threshold)
979
1131
  throw new Error(`API Assertion Failed: Expected response time to be less than ${threshold}ms, but got ${apiState.responseTime}ms`);
980
1132
  }
981
1133
  else {
@@ -998,6 +1150,7 @@ class FlowWorker extends events_1.EventEmitter {
998
1150
  : payload.condition
999
1151
  ? [{ condition: payload.condition, value: payload.value }]
1000
1152
  : [];
1153
+ (0, assertion_contract_1.validateApiAssertions)(assertionsArray);
1001
1154
  for (const assertion of assertionsArray) {
1002
1155
  const condition = assertion.condition;
1003
1156
  const rawExpected = assertion.value;
@@ -1108,10 +1261,10 @@ class FlowWorker extends events_1.EventEmitter {
1108
1261
  "undefined",
1109
1262
  ];
1110
1263
  if (["equals", "contains"].includes(condition) ||
1111
- (isToBe && !knownTypes.includes(expectedLower))) {
1264
+ ((isToBe || isNegated) && !knownTypes.includes(expectedLower))) {
1112
1265
  const strActual = String(actualValue);
1113
1266
  const isEquals = condition === "equals" ||
1114
- (isToBe && !knownTypes.includes(expectedLower));
1267
+ ((isToBe || isNegated) && !knownTypes.includes(expectedLower));
1115
1268
  if (isEquals) {
1116
1269
  const match = strActual === rawExpected;
1117
1270
  const passed = isNegated ? !match : match;
@@ -1162,7 +1315,7 @@ class FlowWorker extends events_1.EventEmitter {
1162
1315
  if (remaining <= 0)
1163
1316
  throw new Error(failure());
1164
1317
  try {
1165
- actual = await (0, deadline_1.withDeadline)(this.web.getAttribute(payload.selector, payload.attribute, payload.elementType), remaining);
1318
+ actual = await (0, deadline_2.withDeadline)(this.web.getAttribute(payload.selector, payload.attribute, payload.elementType), remaining);
1166
1319
  }
1167
1320
  catch (error) {
1168
1321
  throw new Error(`${failure()}\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
@@ -1181,6 +1334,9 @@ class FlowWorker extends events_1.EventEmitter {
1181
1334
  }
1182
1335
  async setupNetworkInterception() {
1183
1336
  const context = this.web.getPage().context();
1337
+ if (this.interceptedContexts.has(context))
1338
+ return;
1339
+ this.interceptedContexts.add(context);
1184
1340
  context.on("response", async (response) => {
1185
1341
  const lockedFileName = this.currentFileName;
1186
1342
  const request = response.request();
@@ -1239,8 +1395,27 @@ class FlowWorker extends events_1.EventEmitter {
1239
1395
  });
1240
1396
  }
1241
1397
  async startStreaming() {
1398
+ clearInterval(this.fallbackStream);
1242
1399
  if (this.config.ci)
1243
1400
  return;
1401
+ if (this.config.browser && this.config.browser !== 'chromium') {
1402
+ const page = this.web.getPage();
1403
+ let busy = false;
1404
+ this.fallbackStream = setInterval(async () => {
1405
+ if (busy || page.isClosed())
1406
+ return;
1407
+ busy = true;
1408
+ try {
1409
+ this.broadcast('BROWSER_FRAME', { base64: (await page.screenshot({ type: 'jpeg', quality: 60 })).toString('base64'), fileName: this.currentFileName });
1410
+ }
1411
+ catch { }
1412
+ finally {
1413
+ busy = false;
1414
+ }
1415
+ }, 250);
1416
+ this.fallbackStream.unref();
1417
+ return;
1418
+ }
1244
1419
  const page = this.web.getPage();
1245
1420
  if (this.currentCdpSession) {
1246
1421
  await this.currentCdpSession.detach().catch(() => { });
@@ -1261,6 +1436,8 @@ class FlowWorker extends events_1.EventEmitter {
1261
1436
  });
1262
1437
  }
1263
1438
  async shutdown() {
1439
+ clearInterval(this.fallbackStream);
1440
+ await this.native.dispose();
1264
1441
  try {
1265
1442
  await this.web.shutdown();
1266
1443
  }
@@ -121,6 +121,7 @@ class PluginManager {
121
121
  get: (url, headers) => this.apiProxy.get(url, headers),
122
122
  post: (url, body, headers) => this.apiProxy.post(url, body, headers),
123
123
  put: (url, body, headers) => this.apiProxy.put(url, body, headers),
124
+ patch: (url, body, headers) => this.apiProxy.patch(url, body, headers),
124
125
  delete: (url, headers) => this.apiProxy.delete(url, headers),
125
126
  },
126
127
  ui: {
@@ -144,6 +145,8 @@ class PluginManager {
144
145
  state: {
145
146
  get: (k) => this.store.get(k),
146
147
  set: (k, v) => this.store.set(k, v),
148
+ delete: (k) => { this.store.delete(k); },
149
+ clear: () => this.store.clear(),
147
150
  },
148
151
  env: {
149
152
  get: (k) => process.env[k],
@@ -3,6 +3,7 @@ export declare class TemporaryStore {
3
3
  set(key: string, value: any): void;
4
4
  get(key: string): any;
5
5
  has(key: string): boolean;
6
+ delete(key: string): boolean;
6
7
  clear(): void;
7
8
  getAll(): Record<string, any>;
8
9
  }
@@ -12,6 +12,9 @@ class TemporaryStore {
12
12
  has(key) {
13
13
  return this.store.has(key);
14
14
  }
15
+ delete(key) {
16
+ return this.store.delete(key);
17
+ }
15
18
  clear() {
16
19
  this.store.clear();
17
20
  }
@@ -5,4 +5,7 @@ export declare class ApiProxy {
5
5
  private formatHeaders;
6
6
  get(url: string, headers?: Record<string, string>): Promise<any>;
7
7
  post(url: string, body: any, headers?: Record<string, string>): Promise<any>;
8
+ put(url: string, body: any, headers?: Record<string, string>): Promise<any>;
9
+ patch(url: string, body: any, headers?: Record<string, string>): Promise<any>;
10
+ delete(url: string, headers?: Record<string, string>): Promise<any>;
8
11
  }
@@ -24,5 +24,21 @@ class ApiProxy {
24
24
  const state = this.adapter.getState();
25
25
  return state.body;
26
26
  }
27
+ async put(url, body, headers) {
28
+ const formattedHeaders = this.formatHeaders(headers);
29
+ const stringifiedBody = typeof body === "string" ? body : JSON.stringify(body);
30
+ await this.adapter.put(url, formattedHeaders, stringifiedBody);
31
+ return this.adapter.getState().body;
32
+ }
33
+ async patch(url, body, headers) {
34
+ const formattedHeaders = this.formatHeaders(headers);
35
+ const stringifiedBody = typeof body === "string" ? body : JSON.stringify(body);
36
+ await this.adapter.patch(url, formattedHeaders, stringifiedBody);
37
+ return this.adapter.getState().body;
38
+ }
39
+ async delete(url, headers) {
40
+ await this.adapter.delete(url, this.formatHeaders(headers));
41
+ return this.adapter.getState().body;
42
+ }
27
43
  }
28
44
  exports.ApiProxy = ApiProxy;
@@ -0,0 +1,2 @@
1
+ /** Bind the manual workspace browser boundary to the actual loopback listener. */
2
+ export declare function isLocalWorkspaceRequest(host: string | undefined, origin: string | undefined, localPort: number | undefined, fetchSite?: string): boolean;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLocalWorkspaceRequest = isLocalWorkspaceRequest;
4
+ const localDashboard_1 = require("./localDashboard");
5
+ /** Bind the manual workspace browser boundary to the actual loopback listener. */
6
+ function isLocalWorkspaceRequest(host, origin, localPort, fetchSite) {
7
+ if (!Number.isInteger(localPort) || !localPort || localPort < 1 || localPort > 65535)
8
+ return false;
9
+ if (!(0, localDashboard_1.isLocalDashboardRequest)(host, origin) || fetchSite === 'cross-site')
10
+ return false;
11
+ try {
12
+ return Number(new URL(`http://${host}`).port) === localPort;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
@@ -33,7 +33,7 @@ function redactPayload(value, secrets = [], depth = 0) {
33
33
  return value.map(item => redactPayload(item, secrets, depth + 1));
34
34
  if (!value || typeof value !== 'object')
35
35
  return value;
36
- if (Buffer.isBuffer(value))
36
+ if (globalThis.Buffer?.isBuffer(value))
37
37
  return '[binary]';
38
38
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive.test(key) ? '[REDACTED]' : redactPayload(item, secrets, depth + 1)]));
39
39
  }
@@ -1,4 +1,14 @@
1
1
  export interface FlowstrideConfig {
2
+ browser?: 'chromium' | 'firefox' | 'webkit';
3
+ browserOptions?: import('playwright').BrowserContextOptions;
4
+ retries?: number;
5
+ failOnFlaky?: boolean;
6
+ trace?: boolean;
7
+ har?: boolean;
8
+ domSnapshots?: boolean;
9
+ shard?: string;
10
+ shardHistory?: string;
11
+ updateBaselines?: boolean;
2
12
  ci?: boolean;
3
13
  flowRoot?: string;
4
14
  projectRoot?: string;
@@ -95,6 +105,7 @@ export interface FlowstrideSDK {
95
105
  get: (url: string, headers?: Record<string, string>) => Promise<any>;
96
106
  post: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
97
107
  put: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
108
+ patch: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
98
109
  delete: (url: string, headers?: Record<string, string>) => Promise<any>;
99
110
  };
100
111
  ui: {
@@ -106,6 +117,8 @@ export interface FlowstrideSDK {
106
117
  state: {
107
118
  get: (key: string) => any;
108
119
  set: (key: string, value: any) => void;
120
+ delete: (key: string) => void;
121
+ clear: () => void;
109
122
  };
110
123
  env: {
111
124
  get: (key: string) => string | undefined;