flowstride 1.0.21 → 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 (81) hide show
  1. package/NATIVE_BROWSER.md +40 -0
  2. package/NATIVE_CI.md +14 -2
  3. package/NATIVE_WORKFLOWS.md +44 -0
  4. package/README.md +8 -1
  5. package/dist/adapters/api/undici.d.ts +3 -1
  6. package/dist/adapters/api/undici.js +17 -3
  7. package/dist/adapters/web/playwright.js +23 -73
  8. package/dist/ci/runtime.js +9 -1
  9. package/dist/cli/commands/run.js +14 -4
  10. package/dist/cli/manual_server/workspace_server.js +90 -74
  11. package/dist/manual/security.d.ts +4 -4
  12. package/dist/native/accessibility.d.ts +2 -1
  13. package/dist/native/accessibility.js +22 -6
  14. package/dist/native/assertions.d.ts +2 -1
  15. package/dist/native/assertions.js +174 -91
  16. package/dist/native/deadline.d.ts +2 -0
  17. package/dist/native/deadline.js +13 -0
  18. package/dist/native/events.d.ts +3 -2
  19. package/dist/native/events.js +35 -8
  20. package/dist/native/functions.d.ts +2 -1
  21. package/dist/native/functions.js +34 -6
  22. package/dist/native/runtime.d.ts +3 -0
  23. package/dist/native/runtime.js +12 -6
  24. package/dist/parser/assertion_contract.d.ts +12 -0
  25. package/dist/parser/assertion_contract.js +56 -0
  26. package/dist/parser/parser.js +14 -6
  27. package/dist/reporting/assertion_result.d.ts +36 -0
  28. package/dist/reporting/assertion_result.js +104 -0
  29. package/dist/reporting/pdf_generator.js +25 -20
  30. package/dist/reporting/runtime_failure.d.ts +10 -0
  31. package/dist/reporting/runtime_failure.js +64 -0
  32. package/dist/reporting/step_status.d.ts +10 -0
  33. package/dist/reporting/step_status.js +44 -0
  34. package/dist/runner/flow_orchestrator.d.ts +1 -0
  35. package/dist/runner/flow_orchestrator.js +8 -3
  36. package/dist/runner/flow_worker.d.ts +8 -1
  37. package/dist/runner/flow_worker.js +119 -41
  38. package/dist/runner/plugin_manager.js +3 -0
  39. package/dist/runner/temporary_store.d.ts +1 -0
  40. package/dist/runner/temporary_store.js +3 -0
  41. package/dist/sdk/api_proxy.d.ts +3 -0
  42. package/dist/sdk/api_proxy.js +16 -0
  43. package/dist/security/localWorkspace.d.ts +2 -0
  44. package/dist/security/localWorkspace.js +17 -0
  45. package/dist/security/redaction.js +1 -1
  46. package/dist/types/index.d.ts +3 -0
  47. package/examples/native-workflows/README.md +144 -0
  48. package/examples/native-workflows/api-and-ui/README.md +80 -0
  49. package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
  50. package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
  51. package/examples/native-workflows/api-ui-otp/README.md +140 -0
  52. package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
  53. package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
  54. package/examples/native-workflows/audio-to-text/README.md +131 -0
  55. package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
  56. package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
  57. package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
  58. package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
  59. package/examples/native-workflows/iframes/README.md +116 -0
  60. package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
  61. package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
  62. package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
  63. package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
  64. package/examples/native-workflows/manual-testing/README.md +85 -0
  65. package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
  66. package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
  67. package/examples/native-workflows/shared/server.mjs +80 -0
  68. package/examples/native-workflows/tables-and-cells/README.md +118 -0
  69. package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
  70. package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
  71. package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
  72. package/examples/native-workflows/variables-and-reuse/README.md +140 -0
  73. package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
  74. package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
  75. package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
  76. package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
  77. package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
  78. package/package.json +11 -8
  79. package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
  80. package/src/ui/dist/index.html +1 -1
  81. package/src/ui/dist/assets/index-DglsL_J2.js +0 -636
@@ -0,0 +1,10 @@
1
+ export interface FailureContext {
2
+ file?: string;
3
+ line?: number;
4
+ profile?: string;
5
+ timeout?: number;
6
+ elapsed?: number;
7
+ }
8
+ /** Preserve the adapter's evidence and nested causes, without printing request bodies or typed values. */
9
+ export declare function commandSecrets(command: string, payload: any): string[];
10
+ export declare function runtimeFailure(command: string, payload: any, error: unknown, context: FailureContext): string;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.commandSecrets = commandSecrets;
4
+ exports.runtimeFailure = runtimeFailure;
5
+ const redaction_1 = require("../security/redaction");
6
+ const assertion_result_1 = require("./assertion_result");
7
+ /** Preserve the adapter's evidence and nested causes, without printing request bodies or typed values. */
8
+ function commandSecrets(command, payload) {
9
+ const secrets = [], seen = new Set();
10
+ const visit = (value, depth) => {
11
+ if (depth > 8 || value == null || seen.has(value))
12
+ return;
13
+ if (typeof value === 'string') {
14
+ if (/^\s*[\[{]/.test(value)) {
15
+ try {
16
+ visit(JSON.parse(value), depth + 1);
17
+ }
18
+ catch { }
19
+ }
20
+ return;
21
+ }
22
+ if (typeof value !== 'object')
23
+ return;
24
+ seen.add(value);
25
+ for (const [key, item] of Object.entries(value).slice(0, 200)) {
26
+ if (/password|passcode|secret|token|authorization|cookie|api.?key|otp/i.test(key) && typeof item === 'string')
27
+ secrets.push(item);
28
+ else
29
+ visit(item, depth + 1);
30
+ }
31
+ };
32
+ visit(payload, 0);
33
+ if (/^(?:flow\.)?(?:type|forceType|passcode)$/i.test(command) && typeof payload?.text === 'string')
34
+ secrets.push(payload.text);
35
+ return secrets;
36
+ }
37
+ function runtimeFailure(command, payload, error, context) {
38
+ const label = command.includes('.') ? command : 'flow.' + command;
39
+ const lines = [`Failed command: ${(0, assertion_result_1.diagnosticValue)(label)}`];
40
+ if (context.file)
41
+ lines.push(`Location: ${(0, assertion_result_1.diagnosticValue)(context.file)}${context.line ? ':' + context.line : ''}`);
42
+ const input = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {};
43
+ const fields = ['selector', 'role', 'name', 'text', 'url', 'path', 'file', 'filename', 'audioPath', 'sessionName', 'jsonPath', 'attribute', 'property', 'key', 'canvas', 'id', 'target'];
44
+ const targets = fields.filter(key => !(/^(?:flow\.)?(?:type|forceType|passcode)$/i.test(command) && key === 'text') && (['string', 'number'].includes(typeof input[key]) || key === 'selector' && input[key] && typeof input[key] === 'object')).map(key => `${key}: ${(0, assertion_result_1.diagnosticValue)(input[key])}`);
45
+ if (targets.length)
46
+ lines.push('Target / resource: ' + targets.join('; '));
47
+ if (context.profile)
48
+ lines.push('Profile: ' + (0, assertion_result_1.diagnosticValue)(context.profile));
49
+ if (context.timeout !== undefined)
50
+ lines.push(`Timeout: ${context.timeout} ms${context.elapsed !== undefined ? '; elapsed: ' + context.elapsed + ' ms' : ''}`);
51
+ const seen = new Set();
52
+ let current = error;
53
+ for (let depth = 0; current != null && depth < 5 && !seen.has(current); depth++) {
54
+ seen.add(current);
55
+ const message = typeof current === 'object' ? current.message || String(current) : String(current);
56
+ // Keep line breaks in Playwright call logs, but escape terminal control sequences.
57
+ const reason = String(message).slice(0, 12000).split('\n').map(line => (0, assertion_result_1.diagnosticValue)(line)).join('\n');
58
+ lines.push((depth ? 'Caused by: ' : 'Reason: ') + reason);
59
+ if (current.code)
60
+ lines.push('Error code: ' + (0, assertion_result_1.diagnosticValue)(current.code));
61
+ current = current.cause;
62
+ }
63
+ return (0, redaction_1.redactText)(lines.join('\n'), commandSecrets(command, payload));
64
+ }
@@ -0,0 +1,10 @@
1
+ /** Presentation shared by the interactive TestCase view and exported PDF. */
2
+ export interface StepStatusPresentation {
3
+ status: string;
4
+ label: string;
5
+ actualResult: string;
6
+ color: string;
7
+ isPassed: boolean;
8
+ isFailed: boolean;
9
+ }
10
+ export declare function stepStatusPresentation(value: unknown): StepStatusPresentation;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stepStatusPresentation = stepStatusPresentation;
4
+ function stepStatusPresentation(value) {
5
+ const status = typeof value === "string"
6
+ ? value.trim().toLowerCase().replace(/[\s-]+/g, "_")
7
+ : value == null ? "pending" : "unknown";
8
+ const result = (state, label, actualResult, color = "#64748b") => ({
9
+ status: state, label, actualResult, color,
10
+ isPassed: state === "passed",
11
+ isFailed: state === "failed" || state === "error",
12
+ });
13
+ switch (status) {
14
+ case "pass":
15
+ case "passed":
16
+ case "success":
17
+ return result("passed", "PASSED", "Action verified successfully.", "#10b981");
18
+ case "fail":
19
+ case "failed":
20
+ return result("failed", "FAILED", "Validation failed.", "#ef4444");
21
+ case "error":
22
+ return result("error", "ERROR", "Step execution failed.", "#ef4444");
23
+ case "skip":
24
+ case "skipped":
25
+ return result("skipped", "SKIPPED", "Step skipped; not executed.");
26
+ case "not_tested":
27
+ return result("not_tested", "NOT TESTED", "Step not tested.");
28
+ case "":
29
+ case "pending":
30
+ return result("pending", "PENDING", "Awaiting execution.");
31
+ case "running":
32
+ case "in_progress":
33
+ return result("running", "RUNNING", "Execution in progress.");
34
+ case "cancelled":
35
+ case "canceled":
36
+ return result("cancelled", "CANCELLED", "Execution cancelled.");
37
+ case "interrupted":
38
+ return result("interrupted", "INTERRUPTED", "Execution interrupted.");
39
+ case "blocked":
40
+ return result("blocked", "BLOCKED", "Execution blocked.");
41
+ default:
42
+ return result("unknown", "UNKNOWN", "Result unavailable.");
43
+ }
44
+ }
@@ -5,6 +5,7 @@ export declare class FlowOrchestrator {
5
5
  private workerCount;
6
6
  private config?;
7
7
  static readonly nativeBrowserProtocol = 2;
8
+ static readonly nativeAssertionProtocol = 1;
8
9
  static readonly nativeCIProtocol = 1;
9
10
  readonly nativeEventProtocol = 1;
10
11
  private wss;
@@ -53,6 +53,7 @@ class FlowOrchestrator {
53
53
  workerCount;
54
54
  config;
55
55
  static nativeBrowserProtocol = 2;
56
+ static nativeAssertionProtocol = 1;
56
57
  static nativeCIProtocol = 1;
57
58
  nativeEventProtocol = 1;
58
59
  wss = null;
@@ -108,9 +109,13 @@ class FlowOrchestrator {
108
109
  }
109
110
  };
110
111
  this.worker.on("worker_event", listener);
111
- await this.worker.runBatch(filesToProcess);
112
- this.worker.off("worker_event", listener);
113
- const passed = ![...this.stepStatusRegistry.values()].some(status => ['failed', 'running', 'not_tested'].includes(status));
112
+ let passed = false;
113
+ try {
114
+ passed = await this.worker.runBatch(filesToProcess);
115
+ }
116
+ finally {
117
+ this.worker.off("worker_event", listener);
118
+ }
114
119
  this.forwardToDashboard("RUN_COMPLETE", { status: passed ? 'success' : 'failed' });
115
120
  return passed;
116
121
  }
@@ -20,11 +20,15 @@ export declare class FlowWorker extends EventEmitter {
20
20
  private sessions;
21
21
  private pluginManager?;
22
22
  static readonly nativeBrowserProtocol = 2;
23
+ static readonly nativeAssertionProtocol = 1;
23
24
  private isSilent;
24
25
  private isAborted;
25
26
  private bailTriggered;
26
27
  private isDebugHalted;
27
28
  private native;
29
+ private currentAssertion?;
30
+ private commandSecretValues;
31
+ private batchPassed;
28
32
  private attempt;
29
33
  private maxAttempts;
30
34
  private fallbackStream?;
@@ -41,6 +45,9 @@ export declare class FlowWorker extends EventEmitter {
41
45
  private lastApiUrl;
42
46
  constructor(config: FlowstrideConfig, web: WebAdapter, api: ApiAdapter, store: any, sessions: SessionManager, pluginManager?: PluginManager | undefined);
43
47
  private broadcast;
48
+ private secretValues;
49
+ private assertionForStep;
50
+ private printAssertion;
44
51
  private emitLog;
45
52
  private translateError;
46
53
  private captureStepContext;
@@ -51,7 +58,7 @@ export declare class FlowWorker extends EventEmitter {
51
58
  private emitApiTelemetry;
52
59
  handleReplayRequest(payload: any): Promise<void>;
53
60
  abort(): void;
54
- runBatch(filesToProcess: string[]): Promise<void>;
61
+ runBatch(filesToProcess: string[]): Promise<boolean>;
55
62
  private runScenarioWithRetries;
56
63
  private runScenario;
57
64
  private waitForPipeline;
@@ -37,8 +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 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");
40
44
  const runtime_1 = require("../native/runtime");
41
- const deadline_1 = require("../ci/deadline");
45
+ const deadline_2 = require("../ci/deadline");
42
46
  const replayRequest_1 = require("../utils/replayRequest");
43
47
  const projectPaths_1 = require("../utils/projectPaths");
44
48
  const redaction_1 = require("../security/redaction");
@@ -59,11 +63,15 @@ class FlowWorker extends events_1.EventEmitter {
59
63
  sessions;
60
64
  pluginManager;
61
65
  static nativeBrowserProtocol = 2;
66
+ static nativeAssertionProtocol = 1;
62
67
  isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
63
68
  isAborted = false;
64
69
  bailTriggered = false;
65
70
  isDebugHalted = false;
66
71
  native;
72
+ currentAssertion;
73
+ commandSecretValues = [];
74
+ batchPassed = true;
67
75
  attempt = 1;
68
76
  maxAttempts = 1;
69
77
  fallbackStream;
@@ -92,8 +100,20 @@ class FlowWorker extends events_1.EventEmitter {
92
100
  broadcast(type, payload) {
93
101
  if (["STEP_ADD", "STEP_UPDATE", "LOG_EVENT"].includes(type))
94
102
  payload = { ...payload, attempt: this.attempt, profile: this.web.activeProfile || "default" };
95
- 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))];
96
- this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
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'));
97
117
  }
98
118
  emitLog(type, status, summary, details, customId, overrideFileName) {
99
119
  const id = customId || Math.random().toString(36).substr(2, 9);
@@ -110,24 +130,16 @@ class FlowWorker extends events_1.EventEmitter {
110
130
  return id;
111
131
  }
112
132
  translateError(error, action, payload) {
113
- const msg = error.message || String(error);
114
- if (msg.includes("API Assertion Failed") ||
115
- msg.includes("Network Execution Error") ||
116
- msg.includes("Extraction Failed") ||
117
- msg.includes("Plugin Execution Failed") ||
118
- msg.includes("Scoping Error"))
119
- return msg;
120
- const target = payload.selector || payload.text || payload.url || "the element";
121
- if (msg.includes("Timeout"))
122
- return `I waited for "${target}" to appear, but it never did.`;
123
- if (msg.includes("intercepted"))
124
- return `I found "${target}", but another element is blocking it.`;
125
- if (msg.includes("Framework rejected"))
126
- return msg;
127
- 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 });
128
134
  }
129
135
  async captureStepContext(payload) {
130
- const page = this.web.getPage();
136
+ let page;
137
+ try {
138
+ page = this.web.getPage();
139
+ }
140
+ catch {
141
+ return {};
142
+ }
131
143
  const context = {};
132
144
  try {
133
145
  const buffer = await page.screenshot({ type: "jpeg", quality: 50 });
@@ -350,6 +362,7 @@ class FlowWorker extends events_1.EventEmitter {
350
362
  this.isAborted = true;
351
363
  }
352
364
  async runBatch(filesToProcess) {
365
+ this.batchPassed = filesToProcess.length > 0;
353
366
  const isCliHeadless = process.argv.includes("--headless");
354
367
  if (isCliHeadless) {
355
368
  this.config.headless = true;
@@ -376,7 +389,7 @@ class FlowWorker extends events_1.EventEmitter {
376
389
  }
377
390
  catch (e) { }
378
391
  if (!this.config.ci)
379
- process.exit(0);
392
+ process.exit(this.isAborted ? 130 : process.exitCode || 0);
380
393
  };
381
394
  process.once("SIGINT", sigintHandler);
382
395
  try {
@@ -470,7 +483,8 @@ class FlowWorker extends events_1.EventEmitter {
470
483
  scenarioName: s.name,
471
484
  fileName: this.currentFileName,
472
485
  });
473
- await this.runScenarioWithRetries(s, f.declarations);
486
+ if (!await this.runScenarioWithRetries(s, f.declarations))
487
+ this.batchPassed = false;
474
488
  if (this.isDebugHalted || this.bailTriggered)
475
489
  break;
476
490
  await this.web
@@ -511,10 +525,11 @@ class FlowWorker extends events_1.EventEmitter {
511
525
  });
512
526
  }
513
527
  catch (e) {
528
+ this.batchPassed = false;
514
529
  if (this.isAborted)
515
530
  break;
516
531
  if (!this.isSilent)
517
- 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())}`));
518
533
  try {
519
534
  const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
520
535
  const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
@@ -532,7 +547,7 @@ class FlowWorker extends events_1.EventEmitter {
532
547
  this.broadcast("FILE_FAILED", {
533
548
  fileName: this.currentFileName,
534
549
  duration: Date.now() - fileStartTime,
535
- error: e.message,
550
+ error: (0, runtime_failure_1.runtimeFailure)("file.setup", { file: this.currentFileName }, e, { file: this.currentFileName }),
536
551
  });
537
552
  }
538
553
  }
@@ -541,7 +556,7 @@ class FlowWorker extends events_1.EventEmitter {
541
556
  console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
542
557
  }
543
558
  else {
544
- 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."));
545
560
  }
546
561
  }
547
562
  }
@@ -559,6 +574,7 @@ class FlowWorker extends events_1.EventEmitter {
559
574
  }
560
575
  process.removeListener("SIGINT", sigintHandler);
561
576
  }
577
+ return this.batchPassed && !this.isAborted && !this.isDebugHalted;
562
578
  }
563
579
  async runScenarioWithRetries(scenario, declarations) {
564
580
  this.maxAttempts = (this.config.retries || 0) + 1;
@@ -567,6 +583,7 @@ class FlowWorker extends events_1.EventEmitter {
567
583
  const globals = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalVariables) : {}, globalSessions = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalBrowserSessions) : {};
568
584
  const storeState = this.maxAttempts > 1 ? structuredClone(this.store.getAll?.()) : undefined;
569
585
  const apiState = this.maxAttempts > 1 ? this.api.checkpointState?.() : undefined;
586
+ let finalPassed = false;
570
587
  for (this.attempt = 1; this.attempt <= this.maxAttempts; this.attempt++) {
571
588
  if (this.isAborted || this.isDebugHalted)
572
589
  break;
@@ -593,7 +610,15 @@ class FlowWorker extends events_1.EventEmitter {
593
610
  const completed = await this.runScenario(structuredClone(scenario), declarations);
594
611
  const browserErrors = this.web.pageErrors?.slice(errorIndex) || [];
595
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
+ }
596
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);
597
622
  if (passed || this.isAborted || this.isDebugHalted)
598
623
  break;
599
624
  if (this.attempt < this.maxAttempts)
@@ -601,26 +626,38 @@ class FlowWorker extends events_1.EventEmitter {
601
626
  }
602
627
  this.attempt = 1;
603
628
  this.maxAttempts = 1;
629
+ return finalPassed;
604
630
  }
605
631
  async runScenario(scenario, globalDeclarations = []) {
606
632
  let hasScenarioFailed = false;
607
633
  for (const originalStep of scenario.steps) {
608
634
  const step = structuredClone(originalStep);
635
+ this.currentAssertion = undefined;
636
+ this.commandSecretValues = [];
609
637
  if (this.isAborted)
610
638
  break;
611
639
  const blockName = step.blockDescription.split(" ")[0];
612
640
  let blockDesc = step.blockDescription.substring(blockName.length).trim();
613
- blockDesc = this.resolveTextVariables(blockDesc);
614
- if (this.dataGen)
615
- blockDesc = this.dataGen.resolve(blockDesc);
616
- step.payload = this.resolveTemplateData(step.payload);
617
- this.currentStepContext = `${blockName} ${blockDesc}`;
618
- const isOpt = step.payload?.optional;
641
+ const stepLabel = (0, assertion_result_1.commandLabel)(step.action, step.payload);
619
642
  if (hasScenarioFailed) {
620
- this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, status: 'not_tested', fileName: this.currentFileName });
621
- 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 });
622
646
  continue;
623
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;
624
661
  const mockSpinner = {
625
662
  text: "",
626
663
  start() {
@@ -647,6 +684,8 @@ class FlowWorker extends events_1.EventEmitter {
647
684
  }).start();
648
685
  this.emitLog("ACTION", "info", `${blockName}: ${blockDesc}`, {
649
686
  command: step.action,
687
+ commandLabel: stepLabel,
688
+ sourceLine: step.line,
650
689
  payload: step.payload,
651
690
  }, undefined, this.currentFileName);
652
691
  this.broadcast("STEP_ADD", {
@@ -654,17 +693,27 @@ class FlowWorker extends events_1.EventEmitter {
654
693
  type: blockName,
655
694
  description: blockDesc,
656
695
  command: step.action,
696
+ commandLabel: stepLabel,
697
+ sourceLine: step.line,
657
698
  status: "running",
699
+ error: null, assertion: null,
658
700
  fileName: this.currentFileName,
659
701
  });
702
+ const stepStarted = Date.now();
660
703
  try {
704
+ if (resolutionError)
705
+ throw resolutionError;
661
706
  await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
662
707
  const stepContext = await this.captureStepContext(step.payload);
663
708
  const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
664
709
  spinner.succeed();
710
+ const assertion = this.assertionForStep(step);
711
+ this.printAssertion(assertion);
665
712
  this.broadcast("STEP_UPDATE", {
666
713
  id: step.id,
667
714
  status: "passed",
715
+ error: null,
716
+ assertion,
668
717
  screenshot: stepContext.screenshot,
669
718
  domSnapshot,
670
719
  fileName: this.currentFileName,
@@ -680,6 +729,7 @@ class FlowWorker extends events_1.EventEmitter {
680
729
  catch (e) {
681
730
  if (this.isAborted)
682
731
  break;
732
+ const assertion = this.assertionForStep(step);
683
733
  const stepContext = await this.captureStepContext(step.payload);
684
734
  const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
685
735
  let strippedDom = "";
@@ -696,12 +746,14 @@ class FlowWorker extends events_1.EventEmitter {
696
746
  if (isOpt) {
697
747
  spinner.warn(chalk_1.default.yellow(`[Skipped]: ${blockDesc}`));
698
748
  this.emitLog("ACTION", "info", `Skipped Optional Step: ${blockDesc}`, {
699
- reason: "Element not present after full timeout.",
749
+ reason: (0, runtime_failure_1.runtimeFailure)(stepLabel, step.payload, e, { file: this.currentFileName, line: step.line }),
700
750
  strippedDom,
701
751
  }, undefined, this.currentFileName);
702
752
  this.broadcast("STEP_UPDATE", {
703
753
  id: step.id,
704
754
  status: "skipped",
755
+ error: (0, runtime_failure_1.runtimeFailure)(stepLabel, step.payload, e, { file: this.currentFileName, line: step.line }),
756
+ assertion,
705
757
  screenshot: stepContext.screenshot,
706
758
  domSnapshot,
707
759
  fileName: this.currentFileName,
@@ -739,10 +791,17 @@ class FlowWorker extends events_1.EventEmitter {
739
791
  else {
740
792
  spinner.fail();
741
793
  }
742
- 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, ''));
743
798
  this.emitLog("ERROR", "error", humanReadableError, {
744
- rawError: e.message,
799
+ rawError: humanReadableError,
800
+ stepId: step.id,
801
+ assertion,
745
802
  command: step.action,
803
+ commandLabel: stepLabel,
804
+ sourceLine: step.line,
746
805
  screenshot: stepContext.screenshot,
747
806
  boundingBox: stepContext.boundingBox,
748
807
  strippedDom,
@@ -750,6 +809,8 @@ class FlowWorker extends events_1.EventEmitter {
750
809
  this.broadcast("STEP_UPDATE", {
751
810
  id: step.id,
752
811
  status: "failed",
812
+ error: humanReadableError,
813
+ assertion,
753
814
  screenshot: stepContext.screenshot,
754
815
  domSnapshot,
755
816
  fileName: this.currentFileName,
@@ -807,9 +868,25 @@ class FlowWorker extends events_1.EventEmitter {
807
868
  '[id*="burger" i]',
808
869
  ].join(", ");
809
870
  }
871
+ if (action === 'expect')
872
+ (0, assertion_contract_1.validateExpectationType)(payload?.type);
810
873
  switch (action) {
811
874
  case 'native': {
812
- const result = await (0, deadline_1.withDeadline)(this.native.execute(payload.pluginMethod, payload.data), this.config.timeout);
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
+ }
813
890
  if (payload.saveAs)
814
891
  this.localVariables[payload.saveAs] = result;
815
892
  break;
@@ -1049,8 +1126,8 @@ class FlowWorker extends events_1.EventEmitter {
1049
1126
  throw new Error(`API Assertion Failed: Expected HTTP status ${expectedStatus}, but got ${apiState.status}`);
1050
1127
  }
1051
1128
  else if (expectType === "responseTime") {
1052
- const threshold = payload.threshold || payload.value;
1053
- if (apiState.responseTime > parseInt(threshold, 10))
1129
+ const threshold = (0, assertion_contract_1.validateResponseTimeThreshold)(payload.threshold ?? payload.value);
1130
+ if (apiState.responseTime > threshold)
1054
1131
  throw new Error(`API Assertion Failed: Expected response time to be less than ${threshold}ms, but got ${apiState.responseTime}ms`);
1055
1132
  }
1056
1133
  else {
@@ -1073,6 +1150,7 @@ class FlowWorker extends events_1.EventEmitter {
1073
1150
  : payload.condition
1074
1151
  ? [{ condition: payload.condition, value: payload.value }]
1075
1152
  : [];
1153
+ (0, assertion_contract_1.validateApiAssertions)(assertionsArray);
1076
1154
  for (const assertion of assertionsArray) {
1077
1155
  const condition = assertion.condition;
1078
1156
  const rawExpected = assertion.value;
@@ -1183,10 +1261,10 @@ class FlowWorker extends events_1.EventEmitter {
1183
1261
  "undefined",
1184
1262
  ];
1185
1263
  if (["equals", "contains"].includes(condition) ||
1186
- (isToBe && !knownTypes.includes(expectedLower))) {
1264
+ ((isToBe || isNegated) && !knownTypes.includes(expectedLower))) {
1187
1265
  const strActual = String(actualValue);
1188
1266
  const isEquals = condition === "equals" ||
1189
- (isToBe && !knownTypes.includes(expectedLower));
1267
+ ((isToBe || isNegated) && !knownTypes.includes(expectedLower));
1190
1268
  if (isEquals) {
1191
1269
  const match = strActual === rawExpected;
1192
1270
  const passed = isNegated ? !match : match;
@@ -1237,7 +1315,7 @@ class FlowWorker extends events_1.EventEmitter {
1237
1315
  if (remaining <= 0)
1238
1316
  throw new Error(failure());
1239
1317
  try {
1240
- 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);
1241
1319
  }
1242
1320
  catch (error) {
1243
1321
  throw new Error(`${failure()}\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
@@ -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;