flowstride 1.0.19 → 1.0.20-rc.6

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 (54) hide show
  1. package/NATIVE_CI.md +179 -0
  2. package/dist/adapters/web/playwright.d.ts +1 -0
  3. package/dist/adapters/web/playwright.js +24 -18
  4. package/dist/ci/config.d.ts +3 -0
  5. package/dist/ci/config.js +77 -0
  6. package/dist/ci/deadline.d.ts +2 -0
  7. package/dist/ci/deadline.js +16 -0
  8. package/dist/ci/init.d.ts +1 -0
  9. package/dist/ci/init.js +21 -0
  10. package/dist/ci/runtime.d.ts +19 -0
  11. package/dist/ci/runtime.js +203 -0
  12. package/dist/cli/commands/run.js +17 -35
  13. package/dist/cli/index.js +78 -54
  14. package/dist/cli/manual_server/workspace_server.js +44 -32
  15. package/dist/cli/utils/cloudSession.d.ts +7 -0
  16. package/dist/cli/utils/cloudSession.js +37 -0
  17. package/dist/cli/utils/proInstaller.d.ts +5 -0
  18. package/dist/cli/utils/proInstaller.js +141 -0
  19. package/dist/game/bridge.d.ts +13 -0
  20. package/dist/game/bridge.js +15 -0
  21. package/dist/game/sdk.d.ts +27 -0
  22. package/dist/game/sdk.js +256 -0
  23. package/dist/game/three-bridge.d.ts +18 -0
  24. package/dist/game/three-bridge.js +59 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +9 -0
  27. package/dist/manual/security.d.ts +183 -0
  28. package/dist/manual/security.js +106 -0
  29. package/dist/runner/data_generators.d.ts +1 -0
  30. package/dist/runner/data_generators.js +3 -0
  31. package/dist/runner/dom_stripper.js +3 -0
  32. package/dist/runner/flow_orchestrator.d.ts +5 -1
  33. package/dist/runner/flow_orchestrator.js +34 -19
  34. package/dist/runner/flow_worker.d.ts +1 -0
  35. package/dist/runner/flow_worker.js +56 -16
  36. package/dist/runner/persistent_vault.js +2 -1
  37. package/dist/runner/plugin_manager.d.ts +1 -0
  38. package/dist/runner/plugin_manager.js +35 -1
  39. package/dist/security/localDashboard.d.ts +2 -0
  40. package/dist/security/localDashboard.js +22 -0
  41. package/dist/security/redaction.d.ts +4 -0
  42. package/dist/security/redaction.js +49 -0
  43. package/dist/types/index.d.ts +43 -0
  44. package/dist/utils/cloud.d.ts +6 -0
  45. package/dist/utils/cloud.js +33 -0
  46. package/dist/utils/projectPaths.d.ts +11 -0
  47. package/dist/utils/projectPaths.js +90 -0
  48. package/examples/native-game/flows/canvas.flow +13 -0
  49. package/examples/native-game/flowstride.config.json +12 -0
  50. package/examples/native-game/server.mjs +12 -0
  51. package/package.json +27 -11
  52. package/src/ui/dist/assets/index-DVwp0K-J.js +462 -0
  53. package/src/ui/dist/index.html +1 -1
  54. package/src/ui/dist/assets/index-DIC22ZK_.js +0 -638
@@ -37,6 +37,8 @@ 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 projectPaths_1 = require("../utils/projectPaths");
41
+ const redaction_1 = require("../security/redaction");
40
42
  const fs = __importStar(require("fs"));
41
43
  const path = __importStar(require("path"));
42
44
  const chalk_1 = __importDefault(require("chalk"));
@@ -55,6 +57,7 @@ class FlowWorker extends events_1.EventEmitter {
55
57
  pluginManager;
56
58
  isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
57
59
  isAborted = false;
60
+ bailTriggered = false;
58
61
  isDebugHalted = false;
59
62
  currentStepContext = "Initializing...";
60
63
  currentFileName = "System";
@@ -76,7 +79,8 @@ class FlowWorker extends events_1.EventEmitter {
76
79
  this.pluginManager = pluginManager;
77
80
  }
78
81
  broadcast(type, payload) {
79
- this.emit("worker_event", { type, payload });
82
+ 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
+ this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
80
84
  }
81
85
  emitLog(type, status, summary, details, customId, overrideFileName) {
82
86
  const id = customId || Math.random().toString(36).substr(2, 9);
@@ -362,8 +366,13 @@ class FlowWorker extends events_1.EventEmitter {
362
366
  }
363
367
  this.activeAudioPath = undefined;
364
368
  this.isAborted = false;
369
+ this.bailTriggered = false;
365
370
  this.isDebugHalted = false;
366
371
  const sigintHandler = async () => {
372
+ if (this.config.ci) {
373
+ this.abort();
374
+ return;
375
+ }
367
376
  if (!this.isSilent) {
368
377
  console.log(chalk_1.default.yellow("\n[Flowstride]: Process interrupted. Gracefully shutting down..."));
369
378
  }
@@ -373,7 +382,8 @@ class FlowWorker extends events_1.EventEmitter {
373
382
  await this.web.shutdown();
374
383
  }
375
384
  catch (e) { }
376
- process.exit(0);
385
+ if (!this.config.ci)
386
+ process.exit(0);
377
387
  };
378
388
  process.once("SIGINT", sigintHandler);
379
389
  try {
@@ -382,7 +392,7 @@ class FlowWorker extends events_1.EventEmitter {
382
392
  const audioMatch = content.match(/(?:flow\.)?injectAudio\s+["']([^"']+)["']/i);
383
393
  if (audioMatch && audioMatch[1]) {
384
394
  const rawPath = audioMatch[1];
385
- const absolutePath = path.resolve(process.cwd(), rawPath);
395
+ const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), rawPath);
386
396
  if (!fs.existsSync(absolutePath)) {
387
397
  throw new Error(`Pre-launch initialization failed: Audio file not found at ${absolutePath}`);
388
398
  }
@@ -391,12 +401,13 @@ class FlowWorker extends events_1.EventEmitter {
391
401
  }
392
402
  }
393
403
  for (const filePath of filesToProcess) {
394
- if (this.isAborted || this.isDebugHalted)
404
+ if (this.isAborted || this.isDebugHalted || this.bailTriggered)
395
405
  break;
396
406
  const fileStartTime = Date.now();
397
407
  try {
398
- const fileName = path.basename(filePath);
408
+ const fileName = (0, projectPaths_1.flowIdentity)(filePath, this.config.flowRoot);
399
409
  this.currentFileName = fileName;
410
+ this.config.testFile = fileName;
400
411
  this.localVariables = {};
401
412
  this.localBrowserSessions = {};
402
413
  this.api.resetState();
@@ -445,14 +456,14 @@ class FlowWorker extends events_1.EventEmitter {
445
456
  });
446
457
  }
447
458
  for (const f of features) {
448
- if (this.isAborted || this.isDebugHalted)
459
+ if (this.isAborted || this.isDebugHalted || this.bailTriggered)
449
460
  break;
450
461
  if (f.scenarios.length === 0)
451
462
  continue;
452
463
  if (!this.isSilent)
453
464
  console.log(chalk_1.default.magenta.bold(` Feature: ${f.name}`));
454
465
  for (const s of f.scenarios) {
455
- if (this.isAborted || this.isDebugHalted)
466
+ if (this.isAborted || this.isDebugHalted || this.bailTriggered)
456
467
  break;
457
468
  if (!this.isSilent)
458
469
  console.log(chalk_1.default.cyan(` Scenario: ${s.name}`));
@@ -461,7 +472,7 @@ class FlowWorker extends events_1.EventEmitter {
461
472
  fileName: this.currentFileName,
462
473
  });
463
474
  await this.runScenario(s, f.declarations);
464
- if (this.isDebugHalted)
475
+ if (this.isDebugHalted || this.bailTriggered)
465
476
  break;
466
477
  await this.web
467
478
  .getPage()
@@ -471,20 +482,24 @@ class FlowWorker extends events_1.EventEmitter {
471
482
  .getPage()
472
483
  .waitForLoadState("networkidle", { timeout: 4000 })
473
484
  .catch(() => { });
474
- await new Promise((resolve) => setTimeout(resolve, 2000));
485
+ if (!this.config.ci)
486
+ await new Promise((resolve) => setTimeout(resolve, 2000));
475
487
  }
476
488
  }
477
489
  if (this.isAborted)
478
490
  break;
479
491
  if (!this.isSilent)
480
492
  console.log(chalk_1.default.gray("\n[Execution]: Finalizing browser actions..."));
481
- await new Promise((resolve) => setTimeout(resolve, 2000));
493
+ if (!this.config.ci)
494
+ await new Promise((resolve) => setTimeout(resolve, 2000));
482
495
  try {
483
- const baseName = this.currentFileName.replace(".flow", "");
496
+ const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
484
497
  const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
485
498
  if (!fs.existsSync(mockCloudDir))
486
499
  fs.mkdirSync(mockCloudDir, { recursive: true });
487
500
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
501
+ for (const message of this.web.pageErrors || [])
502
+ this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
488
503
  await this.web.shutdown(targetVideoPath);
489
504
  if (!this.isSilent)
490
505
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -501,11 +516,13 @@ class FlowWorker extends events_1.EventEmitter {
501
516
  if (!this.isSilent)
502
517
  console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
503
518
  try {
504
- const baseName = this.currentFileName.replace(".flow", "");
519
+ const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
505
520
  const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
506
521
  if (!fs.existsSync(mockCloudDir))
507
522
  fs.mkdirSync(mockCloudDir, { recursive: true });
508
523
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
524
+ for (const message of this.web.pageErrors || [])
525
+ this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
509
526
  await this.web.shutdown(targetVideoPath);
510
527
  if (!this.isSilent)
511
528
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -518,7 +535,7 @@ class FlowWorker extends events_1.EventEmitter {
518
535
  });
519
536
  }
520
537
  }
521
- if (!this.isSilent && !this.isAborted) {
538
+ if (!this.config.ci && !this.isSilent && !this.isAborted) {
522
539
  if (this.isDebugHalted) {
523
540
  console.log(chalk_1.default.cyan("\n[Execution]: Test suite halted by debug flag."));
524
541
  }
@@ -528,10 +545,21 @@ class FlowWorker extends events_1.EventEmitter {
528
545
  }
529
546
  }
530
547
  finally {
548
+ if (this.config.ci && this.isAborted) {
549
+ try {
550
+ const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
551
+ fs.mkdirSync(directory, { recursive: true });
552
+ await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
553
+ }
554
+ catch (error) {
555
+ this.broadcast('WORKER_ERROR', { message: error.message });
556
+ }
557
+ }
531
558
  process.removeListener("SIGINT", sigintHandler);
532
559
  }
533
560
  }
534
561
  async runScenario(scenario, globalDeclarations = []) {
562
+ let hasScenarioFailed = false;
535
563
  for (const step of scenario.steps) {
536
564
  if (this.isAborted)
537
565
  break;
@@ -543,6 +571,11 @@ class FlowWorker extends events_1.EventEmitter {
543
571
  step.payload = this.resolveTemplateData(step.payload);
544
572
  this.currentStepContext = `${blockName} ${blockDesc}`;
545
573
  const isOpt = step.payload?.optional;
574
+ if (hasScenarioFailed) {
575
+ this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, status: 'not_tested', fileName: this.currentFileName });
576
+ this.broadcast('STEP_UPDATE', { id: step.id, status: 'not_tested', fileName: this.currentFileName });
577
+ continue;
578
+ }
546
579
  const mockSpinner = {
547
580
  text: "",
548
581
  start() {
@@ -671,7 +704,11 @@ class FlowWorker extends events_1.EventEmitter {
671
704
  screenshot: stepContext.screenshot,
672
705
  fileName: this.currentFileName,
673
706
  });
674
- throw e;
707
+ hasScenarioFailed = true;
708
+ if (this.config.bail && !this.bailTriggered) {
709
+ this.bailTriggered = true;
710
+ this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
711
+ }
675
712
  }
676
713
  }
677
714
  }
@@ -1167,11 +1204,12 @@ class FlowWorker extends events_1.EventEmitter {
1167
1204
  const sharedId = Math.random().toString(36).substr(2, 9);
1168
1205
  const urlObj = new URL(request.url());
1169
1206
  const endpointName = urlObj.pathname.split("/").pop() || urlObj.pathname;
1170
- const curlString = this.web.synthesizeCurl(request.method(), request.url(), request.headers(), rawPostData);
1207
+ const requestHeaders = await request.allHeaders().catch(() => request.headers());
1208
+ const curlString = this.web.synthesizeCurl(request.method(), request.url(), requestHeaders, rawPostData);
1171
1209
  this.emitLog("REST", response.status() < 400 ? "success" : "error", `${request.method()} ${endpointName}`, {
1172
1210
  method: request.method(),
1173
1211
  url: request.url(),
1174
- requestHeaders: request.headers(),
1212
+ requestHeaders: requestHeaders,
1175
1213
  requestBody: requestBody,
1176
1214
  responseStatus: response.status(),
1177
1215
  responseHeaders: response.headers(),
@@ -1196,6 +1234,8 @@ class FlowWorker extends events_1.EventEmitter {
1196
1234
  });
1197
1235
  }
1198
1236
  async startStreaming() {
1237
+ if (this.config.ci)
1238
+ return;
1199
1239
  const page = this.web.getPage();
1200
1240
  if (this.currentCdpSession) {
1201
1241
  await this.currentCdpSession.detach().catch(() => { });
@@ -34,13 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.PersistentVault = void 0;
37
+ const projectPaths_1 = require("../utils/projectPaths");
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
39
40
  class PersistentVault {
40
41
  vaultPath;
41
42
  data = {};
42
43
  constructor(projectRoot = process.cwd()) {
43
- this.vaultPath = path.join(projectRoot, ".flowstride", "vault.json");
44
+ this.vaultPath = (0, projectPaths_1.projectPath)(projectRoot, path.join(projectRoot, ".flowstride", "vault.json"));
44
45
  this.initialiseVault();
45
46
  }
46
47
  initialiseVault() {
@@ -8,6 +8,7 @@ export declare class PluginManager {
8
8
  private store;
9
9
  private apiProxy;
10
10
  private dbProxy;
11
+ private game;
11
12
  constructor(config: FlowstrideConfig, web: WebAdapter, store: TemporaryStore, apiProxy: ApiProxy);
12
13
  execute(actionName: string, args: any, saveAs?: string): Promise<any>;
13
14
  }
@@ -32,8 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.PluginManager = void 0;
40
+ const projectPaths_1 = require("../utils/projectPaths");
41
+ const deadline_1 = require("../ci/deadline");
42
+ const node_fs_1 = __importDefault(require("node:fs"));
43
+ const sdk_1 = require("../game/sdk");
37
44
  const path = __importStar(require("path"));
38
45
  const db_proxy_1 = require("../sdk/db_proxy");
39
46
  class PluginManager {
@@ -42,12 +49,14 @@ class PluginManager {
42
49
  store;
43
50
  apiProxy;
44
51
  dbProxy;
52
+ game;
45
53
  constructor(config, web, store, apiProxy) {
46
54
  this.config = config;
47
55
  this.web = web;
48
56
  this.store = store;
49
57
  this.apiProxy = apiProxy;
50
58
  this.dbProxy = new db_proxy_1.DbProxy();
59
+ this.game = new sdk_1.GameSDK(() => this.web.getPage(), this.config);
51
60
  }
52
61
  async execute(actionName, args, saveAs) {
53
62
  const lastDotIndex = actionName.lastIndexOf(".");
@@ -56,6 +65,16 @@ class PluginManager {
56
65
  }
57
66
  const pluginFile = actionName.substring(0, lastDotIndex);
58
67
  const functionName = actionName.substring(lastDotIndex + 1);
68
+ if (pluginFile === 'flowstride:game') {
69
+ const actions = ['tap', 'drag', 'swipe', 'key', 'checkpoint', 'snapshot', 'expectVisual', 'state', 'expectState', 'snapshotState', 'expectPreserved', 'fixture', 'select'];
70
+ if (!actions.includes(functionName))
71
+ throw new Error(`Unknown native game action: ${functionName}`);
72
+ const operation = this.game[functionName](args);
73
+ const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
74
+ if (saveAs)
75
+ this.store.set(saveAs, result);
76
+ return result;
77
+ }
59
78
  let fullPath;
60
79
  if (pluginFile.includes("/") || pluginFile.includes("\\")) {
61
80
  fullPath = path.resolve(process.cwd(), `${pluginFile}.ts`);
@@ -64,6 +83,7 @@ class PluginManager {
64
83
  const pluginsPath = this.config.pluginsDir || path.join(process.cwd(), "plugins");
65
84
  fullPath = path.resolve(pluginsPath, `${pluginFile}.ts`);
66
85
  }
86
+ fullPath = (0, projectPaths_1.projectPath)(process.cwd(), fullPath);
67
87
  try {
68
88
  let module;
69
89
  if (fullPath.endsWith(".ts")) {
@@ -84,6 +104,19 @@ class PluginManager {
84
104
  throw new Error(`Plugin function "${functionName}" not found in ${fullPath}`);
85
105
  }
86
106
  const sdk = {
107
+ game: this.game,
108
+ browser: { page: () => this.web.getPage() },
109
+ run: { device: this.config.device || 'desktop', artifactsDir: this.config.artifactsDir || path.resolve('flowstride-artifacts'),
110
+ attach: (name, data) => {
111
+ if (!/^[a-zA-Z0-9_.-]+$/.test(name) || name === '.' || name === '..')
112
+ throw new Error('Attachment name must be a simple filename.');
113
+ const directory = this.config.artifactsDir || path.resolve('flowstride-artifacts');
114
+ node_fs_1.default.mkdirSync(directory, { recursive: true });
115
+ const prefix = (0, projectPaths_1.artifactPrefix)(this.config.testFile);
116
+ const file = path.join(directory, prefix + name);
117
+ node_fs_1.default.writeFileSync(file, data);
118
+ return file;
119
+ } },
87
120
  api: {
88
121
  get: (url, headers) => this.apiProxy.get(url, headers),
89
122
  post: (url, body, headers) => this.apiProxy.post(url, body, headers),
@@ -150,7 +183,8 @@ class PluginManager {
150
183
  .substring(2, 2 + length),
151
184
  },
152
185
  };
153
- const result = await action(sdk, args);
186
+ const operation = action(sdk, args);
187
+ const result = this.config.ci ? await (0, deadline_1.withDeadline)(operation, this.config.timeout) : await operation;
154
188
  if (saveAs) {
155
189
  this.store.set(saveAs, result);
156
190
  }
@@ -0,0 +1,2 @@
1
+ /** Reject remote hosts and browser origins before exposing local debugger data. */
2
+ export declare function isLocalDashboardRequest(host?: string, origin?: string): boolean;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLocalDashboardRequest = isLocalDashboardRequest;
4
+ /** Reject remote hosts and browser origins before exposing local debugger data. */
5
+ function isLocalDashboardRequest(host, origin) {
6
+ if (!host)
7
+ return false;
8
+ try {
9
+ const target = new URL(`http://${host}`);
10
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) || !target.port || target.username || target.password || target.pathname !== '/' || target.search || target.hash)
11
+ return false;
12
+ if (origin !== undefined) {
13
+ const source = new URL(origin);
14
+ if (source.origin !== target.origin || source.pathname !== '/' || source.search || source.hash || source.username || source.password)
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ export declare function redactText(input: string, secrets?: string[]): string;
2
+ export declare function redactPayload(value: any, secrets?: string[], depth?: number): any;
3
+ /** Raw network commands belong only to the local interactive debugger. */
4
+ export declare function redactWorkerPayload(type: string, payload: any, secrets: string[], localDebug?: boolean): any;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.redactText = redactText;
4
+ exports.redactPayload = redactPayload;
5
+ exports.redactWorkerPayload = redactWorkerPayload;
6
+ const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|strippedDom/i;
7
+ function redactText(input, secrets = []) {
8
+ let value = String(input);
9
+ for (const secret of secrets.filter(s => typeof s === 'string' && s.length >= 4).sort((a, b) => b.length - a.length))
10
+ value = value.split(secret).join('[REDACTED]');
11
+ value = value.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]')
12
+ .replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g, '[REDACTED]')
13
+ .replace(/(["']?(?:password|passcode|secret|token|otp|authorization|cookie|api[_-]?key|master[_-]?key)["']?\s*[:=]\s*)(["']?)[^\s,;"'<>}]+\2/gi, '$1[REDACTED]')
14
+ .replace(/([?&](?:ticket|token|signature|x-amz-[^=&#]+)=)[^&#\s]+/gi, '$1[REDACTED]');
15
+ return value;
16
+ }
17
+ function redactPayload(value, secrets = [], depth = 0) {
18
+ if (depth > 12)
19
+ return '[omitted]';
20
+ if (typeof value === 'string') {
21
+ if (/^data:image\//.test(value))
22
+ return value;
23
+ const trimmed = value.trim();
24
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
25
+ try {
26
+ return JSON.stringify(redactPayload(JSON.parse(value), secrets, depth + 1));
27
+ }
28
+ catch { }
29
+ }
30
+ return redactText(value, secrets);
31
+ }
32
+ if (Array.isArray(value))
33
+ return value.map(item => redactPayload(item, secrets, depth + 1));
34
+ if (!value || typeof value !== 'object')
35
+ return value;
36
+ if (Buffer.isBuffer(value))
37
+ return '[binary]';
38
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive.test(key) ? '[REDACTED]' : redactPayload(item, secrets, depth + 1)]));
39
+ }
40
+ /** Raw network commands belong only to the local interactive debugger. */
41
+ function redactWorkerPayload(type, payload, secrets, localDebug = false) {
42
+ const result = redactPayload(payload, secrets);
43
+ if (!localDebug || !payload || !result)
44
+ return result;
45
+ if (type === 'LOG_EVENT' && ['REST', 'GRAPHQL'].includes(payload.type) && typeof payload.details?.curlString === 'string') {
46
+ result.details.rawCurlString = payload.details.curlString;
47
+ }
48
+ return result;
49
+ }
@@ -1,4 +1,38 @@
1
1
  export interface FlowstrideConfig {
2
+ ci?: boolean;
3
+ flowRoot?: string;
4
+ projectRoot?: string;
5
+ cloudSyncResult?: {
6
+ reporting: string;
7
+ video: string;
8
+ error?: unknown;
9
+ };
10
+ device?: 'desktop' | 'phone' | 'landscape';
11
+ viewport?: {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ testDir?: string;
16
+ artifactsDir?: string;
17
+ recordVideo?: boolean;
18
+ browserErrors?: 'fail' | 'record';
19
+ webServer?: {
20
+ command: string;
21
+ url: string;
22
+ cwd?: string;
23
+ timeout?: number;
24
+ };
25
+ testFile?: string;
26
+ onRunEvent?: (event: any) => void;
27
+ bail?: boolean;
28
+ isCloudSyncActive?: boolean;
29
+ machineToken?: string | null;
30
+ workspaceId?: string | null;
31
+ manifest?: {
32
+ plan?: string;
33
+ maxWorkers?: number | 'unlimited';
34
+ [key: string]: any;
35
+ } | null;
2
36
  baseUrl?: string;
3
37
  timeout: number;
4
38
  headless: boolean;
@@ -46,6 +80,15 @@ export interface SessionDefinition {
46
80
  };
47
81
  }
48
82
  export interface FlowstrideSDK {
83
+ game: import('../game/sdk').GameSDK;
84
+ browser: {
85
+ page: () => import('playwright').Page;
86
+ };
87
+ run: {
88
+ device: string;
89
+ artifactsDir: string;
90
+ attach: (name: string, data: string | Buffer) => string;
91
+ };
49
92
  api: {
50
93
  get: (url: string, headers?: Record<string, string>) => Promise<any>;
51
94
  post: (url: string, body: any, headers?: Record<string, string>) => Promise<any>;
@@ -0,0 +1,6 @@
1
+ export declare function cloudApiBase(env?: Record<string, string | undefined>): string;
2
+ export declare function cloudFailure(error: any, secrets?: string[]): {
3
+ status: number | undefined;
4
+ code: string | undefined;
5
+ message: string;
6
+ };
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cloudApiBase = cloudApiBase;
4
+ exports.cloudFailure = cloudFailure;
5
+ const redaction_1 = require("../security/redaction");
6
+ function cloudApiBase(env = process.env) {
7
+ const value = env.FLOWSTRIDE_API_URL || env.NEXTJS_WEBHOOK_URL || 'https://cloud.flowstride.io';
8
+ const url = new URL(value);
9
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
10
+ throw new Error('Cloud URL must be an HTTP(S) URL without credentials or query parameters.');
11
+ const pathname = url.pathname.replace(/\/api\/cloud\/execution-complete\/?$/, '').replace(/\/+$/, '');
12
+ return url.origin + pathname;
13
+ }
14
+ function cloudFailure(error, secrets = []) {
15
+ const status = Number(error?.response?.status) || undefined;
16
+ const code = typeof error?.response?.data?.code === 'string' ? error.response.data.code.slice(0, 80) : typeof error?.code === 'string' ? error.code.slice(0, 80) : undefined;
17
+ const authenticationMessages = {
18
+ TOKEN_EXPIRED: 'This developer token has expired. Generate a replacement; logging in with the same key does not renew it.',
19
+ TOKEN_REVOKED: 'This developer token has been revoked. Use a replacement token.',
20
+ TOKEN_INVALID: 'Cloud did not recognize a valid developer token. Check the credential source and Cloud URL.',
21
+ TOKEN_REQUIRED: 'Cloud requires a Bearer developer token.',
22
+ TOKEN_SCOPE_DENIED: 'This token requires execution or admin scope for CLI runs.',
23
+ };
24
+ const message = (status === 401 || status === 403) && code && authenticationMessages[code] ? authenticationMessages[code]
25
+ : status === 401 ? 'Cloud rejected authentication. Check the selected token and Cloud URL; this response does not confirm expiration.'
26
+ : status === 403 ? 'Workspace permission or feature policy rejected this request.'
27
+ : status === 413 ? 'The Cloud/proxy upload limit was exceeded; evidence remains local.'
28
+ : status === 429 ? 'The Cloud request rate limit was reached.'
29
+ : status && status >= 500 ? 'Cloud service failed; evidence remains local.'
30
+ : status === 400 ? 'Cloud rejected the report schema; verify matching CLI and Cloud releases.'
31
+ : 'Cloud could not be reached; verify the configured URL and network connection.';
32
+ return { status, code: code ? (0, redaction_1.redactText)(code, secrets) : undefined, message };
33
+ }
@@ -0,0 +1,11 @@
1
+ export declare function projectPath(root: string, target: string): string;
2
+ export declare function discoverFlows(target: string, projectRoot?: string): string[];
3
+ export declare function flowIdentity(file: string, root?: string): string;
4
+ export declare function videoFileName(identity: string): string;
5
+ export declare function selectedFeatures<T extends {
6
+ scenarios: {
7
+ isOnly?: boolean;
8
+ steps: unknown[];
9
+ }[];
10
+ }>(features: T[]): T[];
11
+ export declare function artifactPrefix(identity?: string): string;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.projectPath = projectPath;
7
+ exports.discoverFlows = discoverFlows;
8
+ exports.flowIdentity = flowIdentity;
9
+ exports.videoFileName = videoFileName;
10
+ exports.selectedFeatures = selectedFeatures;
11
+ exports.artifactPrefix = artifactPrefix;
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const node_crypto_1 = require("node:crypto");
15
+ function projectPath(root, target) {
16
+ root = node_path_1.default.resolve(root);
17
+ target = node_path_1.default.resolve(root, target);
18
+ const inside = (base, value) => {
19
+ const relative = node_path_1.default.relative(base, value);
20
+ return relative !== '..' && !relative.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(relative);
21
+ };
22
+ if (!inside(root, target))
23
+ throw new Error(`Path must remain inside the project: ${target}`);
24
+ const realRoot = node_fs_1.default.realpathSync(root);
25
+ let parent = target;
26
+ const suffix = [];
27
+ while (true) {
28
+ try {
29
+ node_fs_1.default.lstatSync(parent);
30
+ break;
31
+ }
32
+ catch (error) {
33
+ if (error.code !== 'ENOENT')
34
+ throw error;
35
+ suffix.unshift(node_path_1.default.basename(parent));
36
+ const next = node_path_1.default.dirname(parent);
37
+ if (next === parent)
38
+ throw error;
39
+ parent = next;
40
+ }
41
+ }
42
+ const real = node_path_1.default.join(node_fs_1.default.realpathSync(parent), ...suffix);
43
+ if (!inside(realRoot, real))
44
+ throw new Error(`Symlink escapes the project: ${target}`);
45
+ return target;
46
+ }
47
+ function discoverFlows(target, projectRoot = process.cwd()) {
48
+ const files = [];
49
+ const visited = new Set();
50
+ const visit = (item) => {
51
+ item = projectPath(projectRoot, item);
52
+ const real = node_fs_1.default.realpathSync(item);
53
+ if (visited.has(real))
54
+ return;
55
+ visited.add(real);
56
+ const stat = node_fs_1.default.statSync(item);
57
+ if (stat.isFile()) {
58
+ if (item.endsWith('.flow'))
59
+ files.push(item);
60
+ return;
61
+ }
62
+ if (!stat.isDirectory())
63
+ return;
64
+ for (const entry of node_fs_1.default.readdirSync(item, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
65
+ if (['node_modules', '.git', '.flowstride', 'flowstride-artifacts'].includes(entry.name))
66
+ continue;
67
+ visit(node_path_1.default.join(item, entry.name));
68
+ }
69
+ };
70
+ visit(target);
71
+ return files.sort((a, b) => Number(!a.endsWith('.first.flow')) - Number(!b.endsWith('.first.flow')) || (a < b ? -1 : a > b ? 1 : 0));
72
+ }
73
+ function flowIdentity(file, root) { return (root ? node_path_1.default.relative(root, file) : node_path_1.default.basename(file)).split(node_path_1.default.sep).join('/'); }
74
+ function videoFileName(identity) {
75
+ if (!identity.includes('/') && identity.length < 180 && !identity.includes('%'))
76
+ return identity.replace(/\.flow$/, '') + '.webm';
77
+ const base = node_path_1.default.posix.basename(identity, '.flow').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 60);
78
+ return `${base}-${(0, node_crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 16)}.webm`;
79
+ }
80
+ function selectedFeatures(features) {
81
+ if (!features.some(f => f.scenarios.some(s => s.isOnly)))
82
+ return features;
83
+ return features.map(f => ({ ...f, scenarios: f.scenarios.filter(s => s.isOnly) }));
84
+ }
85
+ function artifactPrefix(identity) {
86
+ if (!identity)
87
+ return '';
88
+ const safe = identity.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 100);
89
+ return safe + (identity.includes('/') || identity.length > 100 ? '-' + (0, node_crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, 12) : '') + '-';
90
+ }
@@ -0,0 +1,13 @@
1
+ Feature: Native canvas game testing
2
+ let game = "flowstride:game"
3
+ Scenario: A tap changes game state and rendering
4
+ Given "The game is ready"
5
+ flow.open "http://127.0.0.1:5189"
6
+ game.fixture {"name":"reset"}
7
+ game.snapshot {"name":"before","canvas":"#game"}
8
+ When "The player taps the game"
9
+ game.tap {"canvas":"#game","x":0.5,"y":0.5}
10
+ Then "The score and scene change"
11
+ game.expectState {"path":"score","equals":1}
12
+ game.expectVisual {"name":"before","canvas":"#game","minChange":0.9}
13
+ game.checkpoint {"name":"game-result","canvas":"#game"}
@@ -0,0 +1,12 @@
1
+ {
2
+ "testDir": "flows",
3
+ "device": "phone",
4
+ "timeout": 5000,
5
+ "artifactsDir": "flowstride-artifacts",
6
+ "recordVideo": true,
7
+ "webServer": {
8
+ "command": "node server.mjs",
9
+ "url": "http://127.0.0.1:5189",
10
+ "timeout": 10000
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ import http from 'node:http';
2
+ import { registerGame } from 'flowstride/dist/game/bridge.js';
3
+ const html = `<!doctype html><html><head><title>Flowstride native game fixture</title></head><body>
4
+ <canvas id="game" width="300" height="200" style="touch-action:none"></canvas>
5
+ <script>
6
+ const canvas=document.querySelector('canvas'),context=canvas.getContext('2d');
7
+ const state={score:0};
8
+ function draw(){context.fillStyle=state.score?'blue':'red';context.fillRect(0,0,canvas.width,canvas.height);}
9
+ canvas.onpointerdown=()=>{state.score++;draw();};draw();
10
+ const register=${registerGame.toString()};register({readState:()=>state,testMode:true,fixtures:{reset:()=>{state.score=0;draw();}}});
11
+ </script></body></html>`;
12
+ http.createServer((_request,response)=>{response.setHeader('Content-Type','text/html');response.end(html);}).listen(5189,'127.0.0.1');