flowstride 1.0.20-rc.6 → 1.0.21
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 +341 -0
- package/NATIVE_CI.md +88 -169
- package/README.md +46 -214
- package/dist/adapters/api/undici.d.ts +8 -0
- package/dist/adapters/api/undici.js +11 -0
- package/dist/adapters/web/playwright.d.ts +25 -2
- package/dist/adapters/web/playwright.js +285 -21
- 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 +24 -5
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/index.js +24 -5
- package/dist/manual/security.d.ts +2 -2
- package/dist/native/accessibility.d.ts +3 -0
- package/dist/native/accessibility.js +68 -0
- package/dist/native/assertions.d.ts +2 -0
- package/dist/native/assertions.js +140 -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/events.d.ts +34 -0
- package/dist/native/events.js +281 -0
- package/dist/native/evidence.d.ts +3 -0
- package/dist/native/evidence.js +31 -0
- package/dist/native/functions.d.ts +9 -0
- package/dist/native/functions.js +103 -0
- package/dist/native/runtime.d.ts +14 -0
- package/dist/native/runtime.js +154 -0
- package/dist/native/validation.d.ts +107 -0
- package/dist/native/validation.js +84 -0
- package/dist/parser/lexer.js +15 -4
- package/dist/parser/parser.js +1 -1
- package/dist/runner/flow_orchestrator.d.ts +4 -0
- package/dist/runner/flow_orchestrator.js +22 -5
- package/dist/runner/flow_worker.d.ts +7 -0
- package/dist/runner/flow_worker.js +141 -37
- package/dist/security/redaction.js +3 -1
- package/dist/sessions/manager.js +3 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/replayRequest.d.ts +8 -0
- package/dist/utils/replayRequest.js +77 -0
- package/package.json +22 -9
- package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
- package/src/ui/dist/index.html +1 -1
- package/src/ui/dist/assets/index-DVwp0K-J.js +0 -462
|
@@ -37,6 +37,9 @@ 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_1 = require("../native/runtime");
|
|
41
|
+
const deadline_1 = require("../ci/deadline");
|
|
42
|
+
const replayRequest_1 = require("../utils/replayRequest");
|
|
40
43
|
const projectPaths_1 = require("../utils/projectPaths");
|
|
41
44
|
const redaction_1 = require("../security/redaction");
|
|
42
45
|
const fs = __importStar(require("fs"));
|
|
@@ -55,10 +58,16 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
55
58
|
store;
|
|
56
59
|
sessions;
|
|
57
60
|
pluginManager;
|
|
61
|
+
static nativeBrowserProtocol = 2;
|
|
58
62
|
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
59
63
|
isAborted = false;
|
|
60
64
|
bailTriggered = false;
|
|
61
65
|
isDebugHalted = false;
|
|
66
|
+
native;
|
|
67
|
+
attempt = 1;
|
|
68
|
+
maxAttempts = 1;
|
|
69
|
+
fallbackStream;
|
|
70
|
+
interceptedContexts = new WeakSet();
|
|
62
71
|
currentStepContext = "Initializing...";
|
|
63
72
|
currentFileName = "System";
|
|
64
73
|
activeAudioPath = undefined;
|
|
@@ -77,8 +86,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
77
86
|
this.store = store;
|
|
78
87
|
this.sessions = sessions;
|
|
79
88
|
this.pluginManager = pluginManager;
|
|
89
|
+
this.native = new runtime_1.NativeRuntime(web, config);
|
|
90
|
+
this.web.onPageChanged = async () => { await this.startStreaming(); await this.setupNetworkInterception(); };
|
|
80
91
|
}
|
|
81
92
|
broadcast(type, payload) {
|
|
93
|
+
if (["STEP_ADD", "STEP_UPDATE", "LOG_EVENT"].includes(type))
|
|
94
|
+
payload = { ...payload, attempt: this.attempt, profile: this.web.activeProfile || "default" };
|
|
82
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))];
|
|
83
96
|
this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
|
|
84
97
|
}
|
|
@@ -282,6 +295,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
282
295
|
url: cleanUrl,
|
|
283
296
|
requestHeaders: formattedHeaders,
|
|
284
297
|
requestBody: parsedReqBody,
|
|
298
|
+
rawReplayRequest: { method: method.toUpperCase(), url: cleanUrl, headers: formattedHeaders, body: rawBodyString },
|
|
285
299
|
responseStatus: state.status,
|
|
286
300
|
responseHeaders: state.headers,
|
|
287
301
|
responseBody: state.body,
|
|
@@ -304,33 +318,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
304
318
|
});
|
|
305
319
|
}
|
|
306
320
|
async handleReplayRequest(payload) {
|
|
321
|
+
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : undefined;
|
|
307
322
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (payload.headers)
|
|
312
|
-
parsedHeaders = JSON.parse(payload.headers);
|
|
313
|
-
}
|
|
314
|
-
catch (e) { }
|
|
315
|
-
try {
|
|
316
|
-
if (payload.body)
|
|
317
|
-
parsedBody = JSON.parse(payload.body);
|
|
318
|
-
}
|
|
319
|
-
catch (e) {
|
|
320
|
-
parsedBody = payload.body;
|
|
321
|
-
}
|
|
322
|
-
const fetchOptions = {
|
|
323
|
-
method: payload.method,
|
|
324
|
-
headers: parsedHeaders,
|
|
325
|
-
};
|
|
326
|
-
if (parsedBody && payload.method !== "GET" && payload.method !== "HEAD") {
|
|
327
|
-
fetchOptions.body =
|
|
328
|
-
typeof parsedBody === "string"
|
|
329
|
-
? parsedBody
|
|
330
|
-
: JSON.stringify(parsedBody);
|
|
331
|
-
}
|
|
332
|
-
const response = await fetch(payload.url, fetchOptions);
|
|
333
|
-
const status = response.status;
|
|
323
|
+
const { url, options } = (0, replayRequest_1.buildReplayRequest)(payload);
|
|
324
|
+
options.signal = AbortSignal.timeout(this.config.timeout || 30000);
|
|
325
|
+
const response = await fetch(url, options);
|
|
334
326
|
const text = await response.text();
|
|
335
327
|
let responseBody;
|
|
336
328
|
try {
|
|
@@ -339,17 +331,18 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
339
331
|
catch {
|
|
340
332
|
responseBody = text;
|
|
341
333
|
}
|
|
342
|
-
this.broadcast(
|
|
343
|
-
status,
|
|
344
|
-
responseBody,
|
|
334
|
+
this.broadcast('REPLAY_RES', {
|
|
335
|
+
status: response.status, responseBody, error: null, requestId,
|
|
345
336
|
fileName: this.currentFileName,
|
|
346
337
|
});
|
|
347
338
|
}
|
|
348
339
|
catch (error) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
340
|
+
const code = error instanceof replayRequest_1.ReplayRequestError ? 'REPLAY_INVALID_REQUEST'
|
|
341
|
+
: error?.name === 'TimeoutError' ? 'REPLAY_TIMEOUT' : 'REPLAY_NETWORK_ERROR';
|
|
342
|
+
const message = (0, redaction_1.redactText)(String(error?.message || 'Replay request failed'), [this.config.machineToken || '']);
|
|
343
|
+
this.broadcast('REPLAY_RES', {
|
|
344
|
+
status: null, error: code, requestId,
|
|
345
|
+
responseBody: { error: message, code }, fileName: this.currentFileName,
|
|
353
346
|
});
|
|
354
347
|
}
|
|
355
348
|
}
|
|
@@ -412,7 +405,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
412
405
|
this.localBrowserSessions = {};
|
|
413
406
|
this.api.resetState();
|
|
414
407
|
this.dataGen = new data_generators_1.DataGenerators();
|
|
408
|
+
await this.native.dispose();
|
|
415
409
|
await this.web.initialise(this.activeAudioPath);
|
|
410
|
+
if (typeof this.web.getBrowserInfo === 'function') {
|
|
411
|
+
const browser = this.web.getBrowserInfo();
|
|
412
|
+
console.log(`[Browser]: ${browser.engine} ${browser.version}; ${browser.headless ? 'headless' : 'headed'}; ${fileName}`);
|
|
413
|
+
this.broadcast('BROWSER_INFO', { ...browser, fileName });
|
|
414
|
+
}
|
|
416
415
|
await this.startStreaming();
|
|
417
416
|
await this.setupNetworkInterception();
|
|
418
417
|
const content = fs.readFileSync(filePath, "utf8");
|
|
@@ -471,7 +470,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
471
470
|
scenarioName: s.name,
|
|
472
471
|
fileName: this.currentFileName,
|
|
473
472
|
});
|
|
474
|
-
await this.
|
|
473
|
+
await this.runScenarioWithRetries(s, f.declarations);
|
|
475
474
|
if (this.isDebugHalted || this.bailTriggered)
|
|
476
475
|
break;
|
|
477
476
|
await this.web
|
|
@@ -500,6 +499,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
500
499
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
501
500
|
for (const message of this.web.pageErrors || [])
|
|
502
501
|
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
502
|
+
await this.native.dispose();
|
|
503
503
|
await this.web.shutdown(targetVideoPath);
|
|
504
504
|
if (!this.isSilent)
|
|
505
505
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -523,6 +523,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
523
523
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
524
524
|
for (const message of this.web.pageErrors || [])
|
|
525
525
|
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
526
|
+
await this.native.dispose();
|
|
526
527
|
await this.web.shutdown(targetVideoPath);
|
|
527
528
|
if (!this.isSilent)
|
|
528
529
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -549,6 +550,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
549
550
|
try {
|
|
550
551
|
const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
|
|
551
552
|
fs.mkdirSync(directory, { recursive: true });
|
|
553
|
+
await this.native.dispose();
|
|
552
554
|
await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
|
|
553
555
|
}
|
|
554
556
|
catch (error) {
|
|
@@ -558,9 +560,52 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
558
560
|
process.removeListener("SIGINT", sigintHandler);
|
|
559
561
|
}
|
|
560
562
|
}
|
|
563
|
+
async runScenarioWithRetries(scenario, declarations) {
|
|
564
|
+
this.maxAttempts = (this.config.retries || 0) + 1;
|
|
565
|
+
const baseline = this.maxAttempts > 1 ? await this.web.checkpointProfiles() : undefined;
|
|
566
|
+
const variables = this.maxAttempts > 1 ? structuredClone(this.localVariables) : {}, sessions = this.maxAttempts > 1 ? structuredClone(this.localBrowserSessions) : {};
|
|
567
|
+
const globals = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalVariables) : {}, globalSessions = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalBrowserSessions) : {};
|
|
568
|
+
const storeState = this.maxAttempts > 1 ? structuredClone(this.store.getAll?.()) : undefined;
|
|
569
|
+
const apiState = this.maxAttempts > 1 ? this.api.checkpointState?.() : undefined;
|
|
570
|
+
for (this.attempt = 1; this.attempt <= this.maxAttempts; this.attempt++) {
|
|
571
|
+
if (this.isAborted || this.isDebugHalted)
|
|
572
|
+
break;
|
|
573
|
+
if (this.attempt > 1) {
|
|
574
|
+
await this.native.dispose();
|
|
575
|
+
if (apiState)
|
|
576
|
+
this.api.restoreState(apiState);
|
|
577
|
+
else
|
|
578
|
+
this.api.resetState();
|
|
579
|
+
this.localVariables = structuredClone(variables);
|
|
580
|
+
this.localBrowserSessions = structuredClone(sessions);
|
|
581
|
+
FlowWorker.globalVariables = structuredClone(globals);
|
|
582
|
+
FlowWorker.globalBrowserSessions = structuredClone(globalSessions);
|
|
583
|
+
if (storeState) {
|
|
584
|
+
this.store.clear();
|
|
585
|
+
for (const [key, value] of Object.entries(structuredClone(storeState)))
|
|
586
|
+
this.store.set(key, value);
|
|
587
|
+
}
|
|
588
|
+
await this.web.restoreProfiles(baseline);
|
|
589
|
+
}
|
|
590
|
+
const started = Date.now();
|
|
591
|
+
this.broadcast('SCENARIO_ATTEMPT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt });
|
|
592
|
+
const errorIndex = this.web.pageErrors?.length || 0;
|
|
593
|
+
const completed = await this.runScenario(structuredClone(scenario), declarations);
|
|
594
|
+
const browserErrors = this.web.pageErrors?.slice(errorIndex) || [];
|
|
595
|
+
const passed = completed && (this.config.browserErrors === 'record' || !browserErrors.length);
|
|
596
|
+
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 });
|
|
597
|
+
if (passed || this.isAborted || this.isDebugHalted)
|
|
598
|
+
break;
|
|
599
|
+
if (this.attempt < this.maxAttempts)
|
|
600
|
+
this.web.pageErrors?.splice(errorIndex);
|
|
601
|
+
}
|
|
602
|
+
this.attempt = 1;
|
|
603
|
+
this.maxAttempts = 1;
|
|
604
|
+
}
|
|
561
605
|
async runScenario(scenario, globalDeclarations = []) {
|
|
562
606
|
let hasScenarioFailed = false;
|
|
563
|
-
for (const
|
|
607
|
+
for (const originalStep of scenario.steps) {
|
|
608
|
+
const step = structuredClone(originalStep);
|
|
564
609
|
if (this.isAborted)
|
|
565
610
|
break;
|
|
566
611
|
const blockName = step.blockDescription.split(" ")[0];
|
|
@@ -615,11 +660,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
615
660
|
try {
|
|
616
661
|
await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
|
|
617
662
|
const stepContext = await this.captureStepContext(step.payload);
|
|
663
|
+
const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
|
|
618
664
|
spinner.succeed();
|
|
619
665
|
this.broadcast("STEP_UPDATE", {
|
|
620
666
|
id: step.id,
|
|
621
667
|
status: "passed",
|
|
622
668
|
screenshot: stepContext.screenshot,
|
|
669
|
+
domSnapshot,
|
|
623
670
|
fileName: this.currentFileName,
|
|
624
671
|
});
|
|
625
672
|
if (step.isStop) {
|
|
@@ -634,6 +681,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
634
681
|
if (this.isAborted)
|
|
635
682
|
break;
|
|
636
683
|
const stepContext = await this.captureStepContext(step.payload);
|
|
684
|
+
const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
|
|
637
685
|
let strippedDom = "";
|
|
638
686
|
try {
|
|
639
687
|
const page = this.web.getPage();
|
|
@@ -655,6 +703,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
655
703
|
id: step.id,
|
|
656
704
|
status: "skipped",
|
|
657
705
|
screenshot: stepContext.screenshot,
|
|
706
|
+
domSnapshot,
|
|
658
707
|
fileName: this.currentFileName,
|
|
659
708
|
});
|
|
660
709
|
continue;
|
|
@@ -702,15 +751,17 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
702
751
|
id: step.id,
|
|
703
752
|
status: "failed",
|
|
704
753
|
screenshot: stepContext.screenshot,
|
|
754
|
+
domSnapshot,
|
|
705
755
|
fileName: this.currentFileName,
|
|
706
756
|
});
|
|
707
757
|
hasScenarioFailed = true;
|
|
708
|
-
if (this.config.bail && !this.bailTriggered) {
|
|
758
|
+
if (this.config.bail && this.attempt >= this.maxAttempts && !this.bailTriggered) {
|
|
709
759
|
this.bailTriggered = true;
|
|
710
760
|
this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
|
|
711
761
|
}
|
|
712
762
|
}
|
|
713
763
|
}
|
|
764
|
+
return !hasScenarioFailed;
|
|
714
765
|
}
|
|
715
766
|
async waitForPipeline() {
|
|
716
767
|
if (!this.activeAudioPath || !fs.existsSync(this.activeAudioPath)) {
|
|
@@ -757,6 +808,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
757
808
|
].join(", ");
|
|
758
809
|
}
|
|
759
810
|
switch (action) {
|
|
811
|
+
case 'native': {
|
|
812
|
+
const result = await (0, deadline_1.withDeadline)(this.native.execute(payload.pluginMethod, payload.data), this.config.timeout);
|
|
813
|
+
if (payload.saveAs)
|
|
814
|
+
this.localVariables[payload.saveAs] = result;
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
760
817
|
case "extract":
|
|
761
818
|
if (["resBody", "resHeader", "cookie"].includes(payload.target)) {
|
|
762
819
|
this.processExtractions([
|
|
@@ -872,6 +929,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
872
929
|
localStorage: stateToSave.localStorage || {},
|
|
873
930
|
sessionStorage: stateToSave.sessionStorage || {},
|
|
874
931
|
fallbackDomain,
|
|
932
|
+
storageOrigin: stateToSave.storageOrigin,
|
|
875
933
|
};
|
|
876
934
|
if (payload.isGlobal) {
|
|
877
935
|
FlowWorker.globalBrowserSessions[payload.name] = hybridState;
|
|
@@ -920,7 +978,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
920
978
|
}
|
|
921
979
|
if (!loadedState)
|
|
922
980
|
throw new Error(`State Error: The session "${payload.name}" does not exist in memory or on disk.`);
|
|
923
|
-
await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain);
|
|
981
|
+
await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain, loadedState.sessionStorage, loadedState.storageOrigin);
|
|
924
982
|
}
|
|
925
983
|
break;
|
|
926
984
|
case "open":
|
|
@@ -1170,6 +1228,27 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1170
1228
|
await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
|
|
1171
1229
|
else if (expectType === "value")
|
|
1172
1230
|
await this.web.expectValue(payload.selector, payload.text, payload.elementType);
|
|
1231
|
+
else if (expectType === "attribute") {
|
|
1232
|
+
const deadline = Date.now() + this.config.timeout;
|
|
1233
|
+
let actual = null;
|
|
1234
|
+
const failure = () => `Attribute assertion failed: ${payload.attribute} on ${payload.selector}. Expected ${payload.fuzzy ? 'to contain ' : ''}${JSON.stringify(payload.text)}, received ${JSON.stringify(actual)}.`;
|
|
1235
|
+
while (true) {
|
|
1236
|
+
const remaining = deadline - Date.now();
|
|
1237
|
+
if (remaining <= 0)
|
|
1238
|
+
throw new Error(failure());
|
|
1239
|
+
try {
|
|
1240
|
+
actual = await (0, deadline_1.withDeadline)(this.web.getAttribute(payload.selector, payload.attribute, payload.elementType), remaining);
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
throw new Error(`${failure()}\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1244
|
+
}
|
|
1245
|
+
if (actual !== null && (payload.fuzzy ? actual.includes(payload.text) : actual === payload.text))
|
|
1246
|
+
break;
|
|
1247
|
+
if (Date.now() >= deadline)
|
|
1248
|
+
throw new Error(failure());
|
|
1249
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(50, deadline - Date.now()))));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1173
1252
|
else if (expectType === "transcript")
|
|
1174
1253
|
await this.web.expectTranscript(payload.text);
|
|
1175
1254
|
break;
|
|
@@ -1177,6 +1256,9 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1177
1256
|
}
|
|
1178
1257
|
async setupNetworkInterception() {
|
|
1179
1258
|
const context = this.web.getPage().context();
|
|
1259
|
+
if (this.interceptedContexts.has(context))
|
|
1260
|
+
return;
|
|
1261
|
+
this.interceptedContexts.add(context);
|
|
1180
1262
|
context.on("response", async (response) => {
|
|
1181
1263
|
const lockedFileName = this.currentFileName;
|
|
1182
1264
|
const request = response.request();
|
|
@@ -1211,6 +1293,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1211
1293
|
url: request.url(),
|
|
1212
1294
|
requestHeaders: requestHeaders,
|
|
1213
1295
|
requestBody: requestBody,
|
|
1296
|
+
rawReplayRequest: { method: request.method(), url: request.url(), headers: requestHeaders, body: rawPostData },
|
|
1214
1297
|
responseStatus: response.status(),
|
|
1215
1298
|
responseHeaders: response.headers(),
|
|
1216
1299
|
responseBody: responseBody,
|
|
@@ -1234,8 +1317,27 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1234
1317
|
});
|
|
1235
1318
|
}
|
|
1236
1319
|
async startStreaming() {
|
|
1320
|
+
clearInterval(this.fallbackStream);
|
|
1237
1321
|
if (this.config.ci)
|
|
1238
1322
|
return;
|
|
1323
|
+
if (this.config.browser && this.config.browser !== 'chromium') {
|
|
1324
|
+
const page = this.web.getPage();
|
|
1325
|
+
let busy = false;
|
|
1326
|
+
this.fallbackStream = setInterval(async () => {
|
|
1327
|
+
if (busy || page.isClosed())
|
|
1328
|
+
return;
|
|
1329
|
+
busy = true;
|
|
1330
|
+
try {
|
|
1331
|
+
this.broadcast('BROWSER_FRAME', { base64: (await page.screenshot({ type: 'jpeg', quality: 60 })).toString('base64'), fileName: this.currentFileName });
|
|
1332
|
+
}
|
|
1333
|
+
catch { }
|
|
1334
|
+
finally {
|
|
1335
|
+
busy = false;
|
|
1336
|
+
}
|
|
1337
|
+
}, 250);
|
|
1338
|
+
this.fallbackStream.unref();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1239
1341
|
const page = this.web.getPage();
|
|
1240
1342
|
if (this.currentCdpSession) {
|
|
1241
1343
|
await this.currentCdpSession.detach().catch(() => { });
|
|
@@ -1256,6 +1358,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1256
1358
|
});
|
|
1257
1359
|
}
|
|
1258
1360
|
async shutdown() {
|
|
1361
|
+
clearInterval(this.fallbackStream);
|
|
1362
|
+
await this.native.dispose();
|
|
1259
1363
|
try {
|
|
1260
1364
|
await this.web.shutdown();
|
|
1261
1365
|
}
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.redactText = redactText;
|
|
4
4
|
exports.redactPayload = redactPayload;
|
|
5
5
|
exports.redactWorkerPayload = redactWorkerPayload;
|
|
6
|
-
const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|strippedDom/i;
|
|
6
|
+
const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|rawReplayRequest|strippedDom/i;
|
|
7
7
|
function redactText(input, secrets = []) {
|
|
8
8
|
let value = String(input);
|
|
9
9
|
for (const secret of secrets.filter(s => typeof s === 'string' && s.length >= 4).sort((a, b) => b.length - a.length))
|
|
@@ -44,6 +44,8 @@ function redactWorkerPayload(type, payload, secrets, localDebug = false) {
|
|
|
44
44
|
return result;
|
|
45
45
|
if (type === 'LOG_EVENT' && ['REST', 'GRAPHQL'].includes(payload.type) && typeof payload.details?.curlString === 'string') {
|
|
46
46
|
result.details.rawCurlString = payload.details.curlString;
|
|
47
|
+
if (payload.details.rawReplayRequest)
|
|
48
|
+
result.details.rawReplayRequest = payload.details.rawReplayRequest;
|
|
47
49
|
}
|
|
48
50
|
return result;
|
|
49
51
|
}
|
package/dist/sessions/manager.js
CHANGED
|
@@ -35,7 +35,7 @@ class SessionManager {
|
|
|
35
35
|
data.lastUsedAt = Date.now();
|
|
36
36
|
this.vault.save(`session_${name}`, data);
|
|
37
37
|
this.activeSession = data;
|
|
38
|
-
await this.web.injectStorage(data.cookies, data.localStorage);
|
|
38
|
+
await this.web.injectStorage(data.cookies, data.localStorage, undefined, data.sessionStorage, data.storageOrigin);
|
|
39
39
|
const definition = this.definitions.get(name);
|
|
40
40
|
if (definition?.inject?.api) {
|
|
41
41
|
const { headerName, template } = definition.inject.api;
|
|
@@ -64,6 +64,8 @@ class SessionManager {
|
|
|
64
64
|
tokens: this.activeSession?.tokens || {},
|
|
65
65
|
cookies: state.cookies,
|
|
66
66
|
localStorage: state.localStorage,
|
|
67
|
+
sessionStorage: state.sessionStorage,
|
|
68
|
+
storageOrigin: state.storageOrigin,
|
|
67
69
|
createdAt: Date.now(),
|
|
68
70
|
lastUsedAt: Date.now(),
|
|
69
71
|
persist,
|
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;
|
|
@@ -53,6 +63,8 @@ export interface SessionData {
|
|
|
53
63
|
tokens: Record<string, any>;
|
|
54
64
|
cookies?: any[];
|
|
55
65
|
localStorage?: Record<string, string>;
|
|
66
|
+
sessionStorage?: Record<string, string>;
|
|
67
|
+
storageOrigin?: string;
|
|
56
68
|
createdAt: number;
|
|
57
69
|
lastUsedAt: number;
|
|
58
70
|
persist: boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare class ReplayRequestError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/** Browser protocol metadata is not a portable set of Fetch request headers. */
|
|
5
|
+
export declare function buildReplayRequest(payload: any): {
|
|
6
|
+
url: string;
|
|
7
|
+
options: RequestInit;
|
|
8
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ReplayRequestError = void 0;
|
|
4
|
+
exports.buildReplayRequest = buildReplayRequest;
|
|
5
|
+
class ReplayRequestError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ReplayRequestError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.ReplayRequestError = ReplayRequestError;
|
|
12
|
+
/** Browser protocol metadata is not a portable set of Fetch request headers. */
|
|
13
|
+
function buildReplayRequest(payload) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(payload?.url);
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
|
18
|
+
throw new Error();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new ReplayRequestError('Replay URL must use HTTP or HTTPS without embedded credentials.');
|
|
22
|
+
}
|
|
23
|
+
const method = typeof payload?.method === 'string' ? payload.method.toUpperCase() : 'GET';
|
|
24
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Z-]+$/.test(method) || ['CONNECT', 'TRACE', 'TRACK'].includes(method)) {
|
|
25
|
+
throw new ReplayRequestError('Replay method is not supported.');
|
|
26
|
+
}
|
|
27
|
+
let supplied = payload?.headers;
|
|
28
|
+
if (typeof supplied === 'string') {
|
|
29
|
+
try {
|
|
30
|
+
supplied = supplied.trim() ? JSON.parse(supplied) : {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new ReplayRequestError('Replay headers must be a valid JSON object.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (supplied === undefined)
|
|
37
|
+
supplied = {};
|
|
38
|
+
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied)) {
|
|
39
|
+
throw new ReplayRequestError('Replay headers must be a JSON object of string values.');
|
|
40
|
+
}
|
|
41
|
+
// Fetch owns the destination, framing and supported compression. Recompute
|
|
42
|
+
// Content-Length after edits and remove all Connection-nominated fields too.
|
|
43
|
+
const excluded = new Set([
|
|
44
|
+
'host', 'content-length', 'accept-encoding', 'connection', 'keep-alive',
|
|
45
|
+
'proxy-connection', 'proxy-authenticate', 'proxy-authorization', 'te',
|
|
46
|
+
'trailer', 'transfer-encoding', 'upgrade', 'expect',
|
|
47
|
+
]);
|
|
48
|
+
for (const [name, value] of Object.entries(supplied)) {
|
|
49
|
+
if (name.toLowerCase() === 'connection' && typeof value === 'string') {
|
|
50
|
+
for (const field of value.split(','))
|
|
51
|
+
excluded.add(field.trim().toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const headers = new Headers();
|
|
55
|
+
for (const [name, value] of Object.entries(supplied)) {
|
|
56
|
+
if (name.startsWith(':') || excluded.has(name.toLowerCase()))
|
|
57
|
+
continue;
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
throw new ReplayRequestError('Replay header values must be strings.');
|
|
60
|
+
try {
|
|
61
|
+
headers.append(name, value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new ReplayRequestError('Replay headers contain an invalid name or value.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const options = { method, headers };
|
|
68
|
+
if (!['GET', 'HEAD'].includes(method) && payload?.body !== undefined && payload.body !== null) {
|
|
69
|
+
try {
|
|
70
|
+
options.body = typeof payload.body === 'string' ? payload.body : JSON.stringify(payload.body);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new ReplayRequestError('Replay body cannot be serialized.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { url: url.href, options };
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowstride",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "An opensource flowfirst test automation framework",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/flowstride_report"
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"dist",
|
|
12
12
|
"src/ui/dist",
|
|
13
13
|
"NATIVE_CI.md",
|
|
14
|
-
"examples/native-game"
|
|
14
|
+
"examples/native-game",
|
|
15
|
+
"NATIVE_BROWSER.md"
|
|
15
16
|
],
|
|
16
17
|
"bin": {
|
|
17
18
|
"flowstride": "dist/cli/index.js"
|
|
@@ -27,8 +28,11 @@
|
|
|
27
28
|
"verify:candidate": "node scripts/verify-candidate.cjs",
|
|
28
29
|
"test:dashboard:unit": "node --test tests/dashboard/store.test.cjs",
|
|
29
30
|
"test:dashboard": "node --test --test-concurrency=1 tests/dashboard/*.test.cjs",
|
|
30
|
-
"test:unit": "node --test tests/native/core.test.cjs tests/native/combined.test.cjs tests/native/curl.test.cjs tests/native/upload.test.cjs tests/dashboard/store.test.cjs",
|
|
31
|
-
"test:security": "node --test tests/manual-security.test.cjs tests/native/curl.test.cjs tests/native/combined.test.cjs"
|
|
31
|
+
"test:unit": "node --test tests/native/core.test.cjs tests/native/combined.test.cjs tests/native/curl.test.cjs tests/native/upload.test.cjs tests/dashboard/store.test.cjs tests/native/replay.test.cjs tests/dashboard/replay.test.cjs tests/native/assertions-sessions.test.cjs tests/native/capabilities.test.cjs tests/native/browser-selection.unit.test.cjs",
|
|
32
|
+
"test:security": "node --test tests/manual-security.test.cjs tests/native/curl.test.cjs tests/native/combined.test.cjs tests/native/replay.test.cjs tests/native/assertions-sessions.test.cjs tests/native/capabilities.test.cjs tests/native/browser-selection.unit.test.cjs",
|
|
33
|
+
"test:replay": "node --test tests/native/replay.test.cjs tests/dashboard/replay.test.cjs tests/dashboard/curl.browser.test.cjs",
|
|
34
|
+
"test:assertions-sessions": "node --test --test-concurrency=1 tests/native/assertions-sessions*.test.cjs",
|
|
35
|
+
"test:capabilities": "node --test --test-concurrency=1 tests/native/capabilities*.test.cjs"
|
|
32
36
|
},
|
|
33
37
|
"keywords": [
|
|
34
38
|
"automation",
|
|
@@ -42,33 +46,42 @@
|
|
|
42
46
|
"node": ">=22.12.0"
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
49
|
+
"@axe-core/playwright": "4.14.0",
|
|
45
50
|
"@inquirer/prompts": "^7.10.1",
|
|
51
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
52
|
+
"@vitejs/plugin-vue": "6.0.10",
|
|
46
53
|
"axios": "^1.20.0",
|
|
47
54
|
"chalk": "^4.1.2",
|
|
48
55
|
"commander": "^11.0.0",
|
|
56
|
+
"dom-accessibility-api": "0.7.1",
|
|
49
57
|
"dotenv": "^17.4.2",
|
|
50
58
|
"open": "^11.0.0",
|
|
51
59
|
"ora": "^5.4.1",
|
|
52
60
|
"playwright": "1.59.1",
|
|
61
|
+
"playwright-core": "1.59.1",
|
|
53
62
|
"playwright-extra": "^4.3.6",
|
|
54
63
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
64
|
+
"sanitize-html": "2.18.1",
|
|
55
65
|
"tar": "^7.5.22",
|
|
56
66
|
"ts-node": "^10.9.2",
|
|
57
67
|
"undici": "^7.30.1",
|
|
68
|
+
"vite": "8.3.5",
|
|
58
69
|
"ws": "^8.22.0",
|
|
59
|
-
"
|
|
60
|
-
"zod": "4.4.3"
|
|
70
|
+
"zod": "4.6.7"
|
|
61
71
|
},
|
|
62
72
|
"devDependencies": {
|
|
63
73
|
"@types/node": "^25.9.1",
|
|
74
|
+
"@types/sanitize-html": "2.16.2",
|
|
64
75
|
"@types/ws": "^8.18.1",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
76
|
+
"react": "18.3.1",
|
|
77
|
+
"react-dom": "18.3.1",
|
|
78
|
+
"three": "0.186.1",
|
|
79
|
+
"typescript": "^5.9.3"
|
|
67
80
|
},
|
|
68
81
|
"optionalDependencies": {
|
|
69
82
|
"@rolldown/binding-darwin-arm64": "^0.15.1"
|
|
70
83
|
},
|
|
71
84
|
"publishConfig": {
|
|
72
|
-
"tag": "
|
|
85
|
+
"tag": "latest"
|
|
73
86
|
}
|
|
74
87
|
}
|