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.
- package/NATIVE_BROWSER.md +381 -0
- package/NATIVE_CI.md +17 -5
- package/NATIVE_WORKFLOWS.md +44 -0
- package/README.md +11 -4
- package/dist/adapters/api/undici.d.ts +11 -1
- package/dist/adapters/api/undici.js +28 -3
- package/dist/adapters/web/playwright.d.ts +22 -2
- package/dist/adapters/web/playwright.js +254 -107
- package/dist/ci/config.js +14 -1
- package/dist/ci/distribution.d.ts +38 -0
- package/dist/ci/distribution.js +134 -0
- package/dist/ci/init.d.ts +4 -1
- package/dist/ci/init.js +13 -2
- package/dist/ci/runtime.js +33 -6
- package/dist/cli/commands/run.js +17 -4
- package/dist/cli/index.js +24 -5
- package/dist/cli/manual_server/workspace_server.js +90 -74
- package/dist/manual/security.d.ts +6 -6
- package/dist/native/accessibility.d.ts +4 -0
- package/dist/native/accessibility.js +84 -0
- package/dist/native/assertions.d.ts +3 -0
- package/dist/native/assertions.js +223 -0
- package/dist/native/compatibility.d.ts +3 -0
- package/dist/native/compatibility.js +16 -0
- package/dist/native/components.d.ts +12 -0
- package/dist/native/components.js +122 -0
- package/dist/native/dashboard.d.ts +7 -0
- package/dist/native/dashboard.js +32 -0
- package/dist/native/deadline.d.ts +2 -0
- package/dist/native/deadline.js +13 -0
- package/dist/native/events.d.ts +35 -0
- package/dist/native/events.js +308 -0
- package/dist/native/evidence.d.ts +3 -0
- package/dist/native/evidence.js +31 -0
- package/dist/native/functions.d.ts +10 -0
- package/dist/native/functions.js +131 -0
- package/dist/native/runtime.d.ts +17 -0
- package/dist/native/runtime.js +160 -0
- package/dist/native/validation.d.ts +107 -0
- package/dist/native/validation.js +84 -0
- package/dist/parser/assertion_contract.d.ts +12 -0
- package/dist/parser/assertion_contract.js +56 -0
- package/dist/parser/lexer.js +15 -4
- package/dist/parser/parser.js +15 -7
- package/dist/reporting/assertion_result.d.ts +36 -0
- package/dist/reporting/assertion_result.js +104 -0
- package/dist/reporting/pdf_generator.js +25 -20
- package/dist/reporting/runtime_failure.d.ts +10 -0
- package/dist/reporting/runtime_failure.js +64 -0
- package/dist/reporting/step_status.d.ts +10 -0
- package/dist/reporting/step_status.js +44 -0
- package/dist/runner/flow_orchestrator.d.ts +5 -0
- package/dist/runner/flow_orchestrator.js +30 -8
- package/dist/runner/flow_worker.d.ts +15 -1
- package/dist/runner/flow_worker.js +219 -42
- package/dist/runner/plugin_manager.js +3 -0
- package/dist/runner/temporary_store.d.ts +1 -0
- package/dist/runner/temporary_store.js +3 -0
- package/dist/sdk/api_proxy.d.ts +3 -0
- package/dist/sdk/api_proxy.js +16 -0
- package/dist/security/localWorkspace.d.ts +2 -0
- package/dist/security/localWorkspace.js +17 -0
- package/dist/security/redaction.js +1 -1
- package/dist/types/index.d.ts +13 -0
- package/examples/native-workflows/README.md +144 -0
- package/examples/native-workflows/api-and-ui/README.md +80 -0
- package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
- package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
- package/examples/native-workflows/api-ui-otp/README.md +140 -0
- package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
- package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
- package/examples/native-workflows/audio-to-text/README.md +131 -0
- package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
- package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
- package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
- package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
- package/examples/native-workflows/iframes/README.md +116 -0
- package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
- package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
- package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
- package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
- package/examples/native-workflows/manual-testing/README.md +85 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
- package/examples/native-workflows/shared/server.mjs +80 -0
- package/examples/native-workflows/tables-and-cells/README.md +118 -0
- package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
- package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
- package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/README.md +140 -0
- package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
- package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
- package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
- package/package.json +23 -9
- package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
- package/src/ui/dist/index.html +1 -1
- 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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
this.broadcast('
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
978
|
-
if (apiState.responseTime >
|
|
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,
|
|
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],
|
package/dist/sdk/api_proxy.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/sdk/api_proxy.js
CHANGED
|
@@ -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,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
|
|
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
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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;
|