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.
- package/NATIVE_BROWSER.md +40 -0
- package/NATIVE_CI.md +14 -2
- package/NATIVE_WORKFLOWS.md +44 -0
- package/README.md +8 -1
- package/dist/adapters/api/undici.d.ts +3 -1
- package/dist/adapters/api/undici.js +17 -3
- package/dist/adapters/web/playwright.js +23 -73
- package/dist/ci/runtime.js +9 -1
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/manual_server/workspace_server.js +90 -74
- package/dist/manual/security.d.ts +4 -4
- package/dist/native/accessibility.d.ts +2 -1
- package/dist/native/accessibility.js +22 -6
- package/dist/native/assertions.d.ts +2 -1
- package/dist/native/assertions.js +174 -91
- package/dist/native/deadline.d.ts +2 -0
- package/dist/native/deadline.js +13 -0
- package/dist/native/events.d.ts +3 -2
- package/dist/native/events.js +35 -8
- package/dist/native/functions.d.ts +2 -1
- package/dist/native/functions.js +34 -6
- package/dist/native/runtime.d.ts +3 -0
- package/dist/native/runtime.js +12 -6
- package/dist/parser/assertion_contract.d.ts +12 -0
- package/dist/parser/assertion_contract.js +56 -0
- package/dist/parser/parser.js +14 -6
- 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 +1 -0
- package/dist/runner/flow_orchestrator.js +8 -3
- package/dist/runner/flow_worker.d.ts +8 -1
- package/dist/runner/flow_worker.js +119 -41
- 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 +3 -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 +11 -8
- package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
- package/src/ui/dist/index.html +1 -1
- 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
|
+
}
|
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
621
|
-
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 });
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
1053
|
-
if (apiState.responseTime >
|
|
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,
|
|
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],
|
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;
|