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.
Files changed (51) hide show
  1. package/NATIVE_BROWSER.md +341 -0
  2. package/NATIVE_CI.md +88 -169
  3. package/README.md +46 -214
  4. package/dist/adapters/api/undici.d.ts +8 -0
  5. package/dist/adapters/api/undici.js +11 -0
  6. package/dist/adapters/web/playwright.d.ts +25 -2
  7. package/dist/adapters/web/playwright.js +285 -21
  8. package/dist/ci/config.js +14 -1
  9. package/dist/ci/distribution.d.ts +38 -0
  10. package/dist/ci/distribution.js +134 -0
  11. package/dist/ci/init.d.ts +4 -1
  12. package/dist/ci/init.js +13 -2
  13. package/dist/ci/runtime.js +24 -5
  14. package/dist/cli/commands/run.js +3 -0
  15. package/dist/cli/index.js +24 -5
  16. package/dist/manual/security.d.ts +2 -2
  17. package/dist/native/accessibility.d.ts +3 -0
  18. package/dist/native/accessibility.js +68 -0
  19. package/dist/native/assertions.d.ts +2 -0
  20. package/dist/native/assertions.js +140 -0
  21. package/dist/native/compatibility.d.ts +3 -0
  22. package/dist/native/compatibility.js +16 -0
  23. package/dist/native/components.d.ts +12 -0
  24. package/dist/native/components.js +122 -0
  25. package/dist/native/dashboard.d.ts +7 -0
  26. package/dist/native/dashboard.js +32 -0
  27. package/dist/native/events.d.ts +34 -0
  28. package/dist/native/events.js +281 -0
  29. package/dist/native/evidence.d.ts +3 -0
  30. package/dist/native/evidence.js +31 -0
  31. package/dist/native/functions.d.ts +9 -0
  32. package/dist/native/functions.js +103 -0
  33. package/dist/native/runtime.d.ts +14 -0
  34. package/dist/native/runtime.js +154 -0
  35. package/dist/native/validation.d.ts +107 -0
  36. package/dist/native/validation.js +84 -0
  37. package/dist/parser/lexer.js +15 -4
  38. package/dist/parser/parser.js +1 -1
  39. package/dist/runner/flow_orchestrator.d.ts +4 -0
  40. package/dist/runner/flow_orchestrator.js +22 -5
  41. package/dist/runner/flow_worker.d.ts +7 -0
  42. package/dist/runner/flow_worker.js +141 -37
  43. package/dist/security/redaction.js +3 -1
  44. package/dist/sessions/manager.js +3 -1
  45. package/dist/types/index.d.ts +12 -0
  46. package/dist/utils/replayRequest.d.ts +8 -0
  47. package/dist/utils/replayRequest.js +77 -0
  48. package/package.json +22 -9
  49. package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
  50. package/src/ui/dist/index.html +1 -1
  51. 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
- let parsedHeaders = undefined;
309
- let parsedBody = undefined;
310
- try {
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("REPLAY_RES", {
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
- this.broadcast("REPLAY_RES", {
350
- status: 500,
351
- responseBody: { error: error.message },
352
- fileName: this.currentFileName,
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.runScenario(s, f.declarations);
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 step of scenario.steps) {
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
  }
@@ -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,
@@ -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.20-rc.6",
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
- "sanitize-html": "2.18.1",
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
- "typescript": "^5.9.3",
66
- "@types/sanitize-html": "2.16.2"
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": "next"
85
+ "tag": "latest"
73
86
  }
74
87
  }