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
package/NATIVE_CI.md ADDED
@@ -0,0 +1,179 @@
1
+ # Native CI and browser-game candidate
2
+
3
+ This candidate adds native CI and game APIs to both engines. Managed Cloud
4
+ execution remains retired. Existing npm OSS / private Pro archive distribution
5
+ and fallback remain supported. Consumer project migrations are managed separately. Native CI uses protocol 1.
6
+ The public CLI selects compatible Pro engines or logs the reason and falls back
7
+ to OSS. Older Pro CLIs may own the npm binary and do not understand the new
8
+ commands; install both candidates when testing the Pro path.
9
+
10
+ The candidates pin Playwright 1.59.1 in both engines; the native install command
11
+ installs that engine's matching Chromium.
12
+
13
+ ## Local candidate check
14
+
15
+ Use a disposable project first. Install the OSS archive, then the Pro archive if
16
+ needed. Both candidates must be installed for testing the new Pro path. The
17
+ currently hosted Cloud download still serves the existing release; this local
18
+ candidate does not change that endpoint or its archive.
19
+
20
+ ```sh
21
+ npm install --save-dev /absolute/path/flowstride-1.0.20-rc.6.tgz
22
+ # Optional: install the local matching Pro candidate after project dependencies.
23
+ npm install --no-save /absolute/path/flowstride-pro-1.0.15-rc.5.tgz
24
+ npx --no-install flowstride install
25
+ npx --no-install flowstride run --ci
26
+ ```
27
+
28
+ Pro continues to require your normal valid developer credentials for licensed
29
+ features. Installing Pro uses `--no-save`: another `npm ci` can remove it. Keep
30
+ project dependency installation before Pro installation. An installed Pro package
31
+ can own `node_modules/.bin/flowstride`; both that entry point and the public OSS
32
+ entry point must be covered when checking fallback.
33
+
34
+ The sample in `examples/native-game` uses a tiny local canvas game. Copy its
35
+ files into a disposable project containing the installed candidates, then run
36
+ the command above. `server.mjs` is the game fixture's HTTP server; it contains no
37
+ Flowstride runner, adapter, worker, or CI orchestration code. A real application
38
+ uses its normal development/start command instead.
39
+
40
+ ## Configuration
41
+
42
+ `flowstride.config.json` supports `testDir`, `baseUrl`, `timeout`, `device`
43
+ (`desktop`, `phone`, `landscape`), optional `viewport`, `pluginsDir`,
44
+ `artifactsDir`, `recordVideo` (boolean), `browserErrors` (`fail` or `record`), and
45
+ `webServer` (`command`, local readiness `url`, optional `cwd` and `timeout`).
46
+ Malformed configuration and unsafe output paths fail before execution.
47
+
48
+ Native CI is selected by `--ci` or a recognized CI environment. It runs without
49
+ a dashboard server, returns nonzero for failed/incomplete/crashed execution,
50
+ and writes latest `summary.json`/`junit.xml` reports at the configured artifact
51
+ root. Media and attachments go into a unique run directory referenced by the
52
+ summary, so local reruns retain history without mixing evidence.
53
+ SIGINT/SIGTERM produce interrupted results. The run owns its optional local
54
+ server and refuses an already-serving readiness URL.
55
+
56
+ Cloud reporting is off by default in native CI. `cloudSync: true` explicitly
57
+ allows existing authenticated reporting; matching Cloud deployment and configured media storage are required. Reporting and
58
+ video status are written separately in the summary and `.flowstride/cloud-sync.json`;
59
+ a reporting failure does not turn a passed test into a failed test. Normal interactive reporting and the restored
60
+ Cloud URL overrides remain separate from retired managed execution.
61
+
62
+ `npx flowstride ci init github` generates a workflow explicitly, never during
63
+ installation, and refuses to overwrite it. It assumes an npm project with a
64
+ committed lockfile. Other package managers and monorepos can use the composite
65
+ action's install-command and working-directory inputs. The composite action is
66
+ in the OSS repository's `action/` directory and becomes remotely usable only
67
+ once that source is pushed. Pin its commit when configuring a consumer workflow.
68
+ Pro jobs pass a developer token as a secret; dependency installation precedes
69
+ Pro login/install, matching-browser installation, execution, and artifact upload.
70
+
71
+ ## Native game actions
72
+
73
+ Register the built-in alias with `let game = "flowstride:game"`. It requires no
74
+ custom plugin file. Available actions:
75
+
76
+ - `tap`: canvas selector, x/y; relative coordinates by default, or `unit: "pixels"`.
77
+ - `drag`/`swipe`: canvas, from/to coordinates, optional duration and input type.
78
+ - `key`: key name and optional hold duration.
79
+ - `checkpoint`: named screenshot, optionally of one canvas.
80
+ - `snapshot` and `expectVisual`: canvas comparison with explicit minChange/maxChange.
81
+ - `state`/`expectState`: exposed state, optional dotted path and expected equals.
82
+ - `snapshotState`/`expectPreserved`: compare selected state across actions/reloads.
83
+ - `fixture`: a named fixture registered by the application in explicit test mode.
84
+ - `select`: a stable object ID resolved by the game bridge, then real pointer input.
85
+
86
+ Visual checks establish pixel differences, not gameplay correctness; pair them
87
+ with state or UI assertions. Mobile profiles emulate Chromium devices, not
88
+ physical phones. Touch dragging currently uses Chromium CDP.
89
+
90
+ For a generic browser game, import `registerGame` from
91
+ `flowstride/dist/game/bridge.js` and supply readState and optional test-only
92
+ fixtures. The function manages the browser bridge; no global setup is required
93
+ in the test runner.
94
+
95
+ ## Three.js integration
96
+
97
+ Import `registerThreeGame` from `flowstride/dist/game/three-bridge.js` in the game
98
+ bundle. Register the live scene, camera, canvas, the application's own Vector3
99
+ and Raycaster constructors, and an optional objects map keyed by stable IDs.
100
+ Alternatively, assign `userData.flowstrideId` to scene objects. Optional local
101
+ pick points use `userData.flowstridePickPoint: [x, y, z]`.
102
+
103
+ The bridge projects the live object and raycasts the live scene; hidden,
104
+ occluded, off-screen, covered or non-pickable objects fail explicitly. It creates
105
+ no renderer or animation loop. Dispose the bridge when the scene is replaced.
106
+
107
+ Optional `readState` provides observable state. `fixtures` are available only
108
+ when the application explicitly registers with `testMode: true`; use that
109
+ setting in dedicated test builds, never public production builds. Naija Throne
110
+ still defines campaign-specific fixtures/rules, using these supported APIs.
111
+
112
+ Custom plugins now receive `sdk.game`, `sdk.browser.page()`, and `sdk.run`
113
+ (device, artifact directory and attach method), alongside the existing SDK.
114
+ They need no global bridge or internal Flowstride class imports.
115
+
116
+ ## Validation before release
117
+
118
+ Build both packages and run `npm run test:native`. For the optional live Three.js
119
+ fixture set `FLOWSTRIDE_THREE_BUILD` to a Three.js build directory containing
120
+ three.module.js and three.core.js. OSS CLI handoff tests use
121
+ `FLOWSTRIDE_PRO_SOURCE` pointing at the built Pro package.
122
+
123
+ Run the real archive install/handoff harness against both candidates, including
124
+ missing/broken Pro and revoked-token fallback. Validate GitHub's Linux execution,
125
+ real licensed download after local acceptance.
126
+ No push, npm publication, Cloud archive replacement or Naija Throne migration
127
+ is performed by building these candidates.
128
+
129
+ ## Combined candidate behavior
130
+
131
+ Node.js 22.12 or newer is required. Flow discovery is recursive; `.first.flow`
132
+ files run before ordinary files. Reports identify nested flows by their relative
133
+ paths, and same-named videos receive distinct filenames. A failed scenario skips
134
+ its remaining steps and continues to later scenarios. `--bail` stops later work;
135
+ Pro executes bail runs sequentially. `only.Scenario:` selects that file's focus
136
+ scenario and report planning uses the same selection.
137
+
138
+ Configuration, plugin, environment, upload, vault and output paths must stay
139
+ inside the project, including resolved symlinks. Move shared fixtures into the
140
+ project rather than referring to external files. `FLOWSTRIDE_API_URL` takes
141
+ precedence over `NEXTJS_WEBHOOK_URL`; the legacy completion suffix is normalized.
142
+ Managed runner commands remain retired.
143
+
144
+ Logs redact credential fields, known generated secrets and signed capabilities.
145
+ Redaction cannot guarantee removal of arbitrary application data or visible
146
+ secrets in screenshots/videos. Review artifact access and test data accordingly.
147
+
148
+ The Pro installer rejects unsafe or incomplete archives, downloads above 32 MiB,
149
+ and checksum mismatches. Older Cloud servers without checksum headers remain
150
+ compatible and emit a warning; `FLOWSTRIDE_PRO_SHA256` can pin an expected digest.
151
+ On failed npm installation the previous Pro directory and CLI launchers are
152
+ restored. This is not a transaction over every project dependency npm may alter;
153
+ use a disposable project for candidate acceptance.
154
+
155
+ ## Dashboard tab history
156
+
157
+ Completed test tabs restore that file's last captured step by default. Selecting
158
+ an earlier step remembers its history cursor separately for each file. A sidebar
159
+ step also selects the owning tab. Return to Live applies only to the selected
160
+ file, and a new suite/rerun clears old cursors and screens. Runner console and
161
+ network panels show the selected file's records; the full Logs view retains
162
+ suite-wide history. Flows/steps without screenshots show an explicit empty
163
+ state rather than another flow's screen.
164
+
165
+ Run `npm run test:dashboard:unit` for the Zustand store/history unit tests and
166
+ `npm run test:dashboard` for those tests plus the real Chromium dashboard
167
+ regression. The browser regression runs real local browser flows, then replays
168
+ their worker events over WebSocket into the built dashboard. It also supplies
169
+ explicit API-only/background-event fixtures. No live Cloud is required.
170
+
171
+ ### Local cURL debugging
172
+
173
+ The interactive local dashboard keeps its cURL preview [REDACTED]. Copy raw cURL
174
+ uses the original request held separately in local memory, including its headers
175
+ and body. Other log fields stay redacted. Native CI and Cloud reporting continue redacting cURL and
176
+ credentials. Raw cURL is not restored for already-redacted historical runs.
177
+ The interactive runner binds to 127.0.0.1 and restricts HTTP Host/Origin and
178
+ WebSocket Origin to its own loopback address and port. Raw commands remain in
179
+ local debugger memory and IPC; they are not written to CI or Cloud reports.
@@ -2,6 +2,7 @@ import { Page } from "playwright";
2
2
  import { FlowstrideConfig } from "../../types";
3
3
  export declare class WebAdapter {
4
4
  private config;
5
+ pageErrors: string[];
5
6
  private browser;
6
7
  private context;
7
8
  private page;
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.WebAdapter = void 0;
40
+ const projectPaths_1 = require("../../utils/projectPaths");
41
+ const playwright_1 = require("playwright");
40
42
  const fs = __importStar(require("fs"));
41
43
  const path = __importStar(require("path"));
42
44
  const playwright_extra_1 = require("playwright-extra");
@@ -44,6 +46,7 @@ const puppeteer_extra_plugin_stealth_1 = __importDefault(require("puppeteer-extr
44
46
  playwright_extra_1.chromium.use((0, puppeteer_extra_plugin_stealth_1.default)());
45
47
  class WebAdapter {
46
48
  config;
49
+ pageErrors = [];
47
50
  browser = null;
48
51
  context = null;
49
52
  page = null;
@@ -75,20 +78,23 @@ class WebAdapter {
75
78
  }
76
79
  this.browser = await playwright_extra_1.chromium.launch({
77
80
  headless: this.config.headless,
81
+ // Native CI finalizes videos before closing the browser on cancellation.
82
+ ...(this.config.ci ? { handleSIGINT: false, handleSIGTERM: false } : {}),
78
83
  args: launchArgs,
79
84
  });
80
- const videoDir = path.join(process.cwd(), ".flowstride", "videos");
85
+ const videoDir = (0, projectPaths_1.projectPath)(process.cwd(), path.join('.flowstride', 'videos'));
81
86
  if (!fs.existsSync(videoDir))
82
87
  fs.mkdirSync(videoDir, { recursive: true });
83
- const customUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
88
+ const profile = this.config.device === 'phone' || this.config.device === 'landscape'
89
+ ? { ...playwright_1.devices['Pixel 7'], viewport: this.config.device === 'landscape' ? { width: 844, height: 390 } : { width: 390, height: 844 } }
90
+ : { viewport: { width: 1280, height: 800 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' };
91
+ const viewport = this.config.viewport || profile.viewport;
84
92
  this.context = await this.browser.newContext({
85
- viewport: null,
86
- userAgent: customUserAgent,
87
- recordVideo: {
88
- dir: videoDir,
89
- size: { width: 1280, height: 800 },
90
- },
93
+ ...profile, viewport,
94
+ ...(this.config.recordVideo === false ? {} : { recordVideo: { dir: videoDir, size: viewport } }),
91
95
  });
96
+ this.pageErrors = [];
97
+ this.context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
92
98
  this.context.on("dialog", async (dialog) => {
93
99
  if (this.dialogHandler) {
94
100
  await this.dialogHandler(dialog);
@@ -120,7 +126,7 @@ class WebAdapter {
120
126
  };
121
127
  }
122
128
  async injectAudio(filePath) {
123
- const absolutePath = path.resolve(process.cwd(), filePath);
129
+ const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), filePath);
124
130
  if (!fs.existsSync(absolutePath)) {
125
131
  throw new Error(`Audio file not found at: ${absolutePath}`);
126
132
  }
@@ -269,7 +275,8 @@ class WebAdapter {
269
275
  });
270
276
  }
271
277
  synthesizeCurl(method, url, headers, postData) {
272
- let curl = `curl -X ${method.toUpperCase()} '${url}'`;
278
+ const safeUrl = url.replace(/'/g, "'\\''");
279
+ let curl = `curl -X ${method.toUpperCase()} '${safeUrl}'`;
273
280
  for (const [key, value] of Object.entries(headers)) {
274
281
  if (key.startsWith(":"))
275
282
  continue;
@@ -479,10 +486,10 @@ class WebAdapter {
479
486
  await targetLoc.evaluate((node) => node.blur());
480
487
  let actualValue = "";
481
488
  try {
482
- actualValue = await targetLoc.inputValue({ timeout: 500 });
489
+ actualValue = await targetLoc.inputValue({ timeout: this.config.timeout });
483
490
  }
484
491
  catch {
485
- actualValue = (await targetLoc.textContent({ timeout: 500 })) || "";
492
+ actualValue = (await targetLoc.textContent({ timeout: this.config.timeout })) || "";
486
493
  }
487
494
  const cleanActual = actualValue.replace(/[^a-zA-Z0-9]/g, "");
488
495
  const cleanExpected = value.replace(/[^a-zA-Z0-9]/g, "");
@@ -609,16 +616,15 @@ class WebAdapter {
609
616
  const targetLoc = await this.checkAmbiguity(loc, selector);
610
617
  await targetLoc.waitFor({ state: "visible" });
611
618
  await targetLoc.scrollIntoViewIfNeeded();
612
- const absolutePath = path.resolve(process.cwd(), filePath);
619
+ const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), filePath);
613
620
  if (!fs.existsSync(absolutePath)) {
614
621
  throw new Error(`File Upload Error: Could not find the file at "${absolutePath}". Please ensure the file exists in your project directory.`);
615
622
  }
616
623
  try {
617
- const fileChooserPromise = this.page.waitForEvent("filechooser", {
618
- timeout: 5000,
619
- });
620
- await targetLoc.click();
621
- const fileChooser = await fileChooserPromise;
624
+ const [fileChooser] = await Promise.all([
625
+ this.page.waitForEvent('filechooser', { timeout: this.config.timeout }),
626
+ targetLoc.click({ timeout: this.config.timeout }),
627
+ ]);
622
628
  await fileChooser.setFiles(absolutePath);
623
629
  }
624
630
  catch (e) {
@@ -0,0 +1,3 @@
1
+ import type { FlowstrideConfig } from '../types';
2
+ export declare function isCIEnvironment(): boolean;
3
+ export declare function loadNativeConfig(root: string, options: Record<string, any>): FlowstrideConfig;
@@ -0,0 +1,77 @@
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.isCIEnvironment = isCIEnvironment;
7
+ exports.loadNativeConfig = loadNativeConfig;
8
+ const projectPaths_1 = require("../utils/projectPaths");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ function isCIEnvironment() {
12
+ return ['FLOWSTRIDE_NATIVE_CI', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'FLOWSTRIDE_API_KEY']
13
+ .some(key => !!process.env[key] && process.env[key] !== 'false' && process.env[key] !== '0');
14
+ }
15
+ function loadNativeConfig(root, options) {
16
+ const file = (0, projectPaths_1.projectPath)(root, options.config || 'flowstride.config.json');
17
+ let input = {};
18
+ if (node_fs_1.default.existsSync(file)) {
19
+ input = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
20
+ if (!input || typeof input !== 'object' || Array.isArray(input))
21
+ throw new Error('Flowstride configuration must be an object.');
22
+ }
23
+ else if (options.config)
24
+ throw new Error(`Configuration not found: ${file}`);
25
+ const config = { ...input, projectRoot: node_path_1.default.resolve(root), timeout: input.timeout ?? 30000, headless: input.headless ?? true,
26
+ ci: !!options.ci || isCIEnvironment(), bail: options.bail || input.bail || false, device: options.device ?? input.device ?? 'desktop',
27
+ artifactsDir: input.artifactsDir ?? 'flowstride-artifacts', testDir: input.testDir ?? 'flows',
28
+ defaultDialogBehavior: input.defaultDialogBehavior ?? 'dismiss',
29
+ recordVideo: input.recordVideo ?? true, browserErrors: input.browserErrors ?? 'fail',
30
+ isCloudSyncActive: options.isCloudSyncActive ?? false, machineToken: options.machineToken ?? null,
31
+ workspaceId: options.workspaceId ?? null, manifest: options.manifest ?? null };
32
+ if (config.ci && input.cloudSync !== true)
33
+ config.isCloudSyncActive = false;
34
+ if (options.headless || config.ci)
35
+ config.headless = true;
36
+ else if (options.headed)
37
+ config.headless = false;
38
+ if (process.env.BASE_URL)
39
+ config.baseUrl = process.env.BASE_URL;
40
+ if (!Number.isSafeInteger(config.timeout) || config.timeout < 1)
41
+ throw new Error('timeout must be a positive integer.');
42
+ if (!['desktop', 'phone', 'landscape'].includes(config.device))
43
+ throw new Error('device must be desktop, phone, or landscape.');
44
+ for (const key of ['headless', 'recordVideo', 'bail'])
45
+ if (config[key] !== undefined && typeof config[key] !== 'boolean')
46
+ throw new Error(`${key} must be boolean.`);
47
+ if (!['fail', 'record'].includes(config.browserErrors))
48
+ throw new Error('browserErrors must be fail or record.');
49
+ for (const key of ['artifactsDir', 'testDir', 'pluginsDir']) {
50
+ if (config[key] === undefined)
51
+ continue;
52
+ if (typeof config[key] !== 'string' || !config[key].trim())
53
+ throw new Error(`${key} must be a nonempty path.`);
54
+ const resolved = (0, projectPaths_1.projectPath)(root, config[key]);
55
+ const relative = node_path_1.default.relative(root, resolved);
56
+ if ((key === 'artifactsDir' && !relative) || relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
57
+ throw new Error(`${key} must be a directory inside the project.`);
58
+ config[key] = resolved;
59
+ }
60
+ if (config.viewport && (!Number.isSafeInteger(config.viewport.width) || config.viewport.width < 1 || !Number.isSafeInteger(config.viewport.height) || config.viewport.height < 1))
61
+ throw new Error('viewport requires positive integer width and height.');
62
+ if (config.webServer) {
63
+ const server = config.webServer;
64
+ if (typeof server.command !== 'string' || !server.command.trim() || typeof server.url !== 'string')
65
+ throw new Error('webServer requires command and url.');
66
+ const url = new URL(server.url);
67
+ if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
68
+ throw new Error('webServer readiness URL must use local HTTP(S).');
69
+ if (server.timeout !== undefined && (!Number.isSafeInteger(server.timeout) || server.timeout < 1))
70
+ throw new Error('webServer.timeout must be positive.');
71
+ server.cwd = (0, projectPaths_1.projectPath)(root, server.cwd || '.');
72
+ const relative = node_path_1.default.relative(root, server.cwd);
73
+ if (relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
74
+ throw new Error('webServer.cwd must be inside the project.');
75
+ }
76
+ return config;
77
+ }
@@ -0,0 +1,2 @@
1
+ /** Bound asynchronous plugin work while observing late rejections safely. */
2
+ export declare function withDeadline<T>(operation: Promise<T> | T, timeout: number): Promise<T>;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withDeadline = withDeadline;
4
+ /** Bound asynchronous plugin work while observing late rejections safely. */
5
+ async function withDeadline(operation, timeout) {
6
+ let timer;
7
+ try {
8
+ return await Promise.race([Promise.resolve(operation), new Promise((_resolve, reject) => {
9
+ timer = setTimeout(() => reject(new Error(`Flowstride step exceeded its ${timeout}ms timeout.`)), timeout);
10
+ })]);
11
+ }
12
+ finally {
13
+ if (timer)
14
+ clearTimeout(timer);
15
+ }
16
+ }
@@ -0,0 +1 @@
1
+ export declare function initCI(provider: string): void;
@@ -0,0 +1,21 @@
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.initCI = initCI;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const config_1 = require("./config");
10
+ function initCI(provider) {
11
+ if (provider !== 'github')
12
+ throw new Error('Supported provider: github.');
13
+ const file = node_path_1.default.resolve('.github/workflows/flowstride.yml');
14
+ if (node_fs_1.default.existsSync(file))
15
+ throw new Error('Flowstride workflow already exists; it was not overwritten.');
16
+ const artifacts = node_path_1.default.relative(process.cwd(), (0, config_1.loadNativeConfig)(process.cwd(), {}).artifactsDir);
17
+ const parent = node_path_1.default.dirname(file);
18
+ node_fs_1.default.mkdirSync(parent, { recursive: true });
19
+ node_fs_1.default.writeFileSync(file, `name: Flowstride\non:\n push:\n pull_request:\n workflow_dispatch:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 20\n steps:\n - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n with:\n persist-credentials: false\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020\n with:\n node-version: 24\n cache: npm\n - run: npm ci\n - name: Install matching browser dependencies\n run: npx --no-install flowstride install --with-deps\n - name: Run native Flowstride\n run: npx --no-install flowstride run --ci\n - name: Retain test evidence\n if: always()\n uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n with:\n name: flowstride-results\n path: ${JSON.stringify(artifacts + "/")}\n retention-days: 14\n`, { flag: 'wx' });
20
+ console.log(`Created ${file}. Commit it when ready. This template uses the project-locked Flowstride installation.`);
21
+ }
@@ -0,0 +1,19 @@
1
+ import type { FlowstrideConfig } from '../types';
2
+ export interface NativeRunResult {
3
+ protocol: 1;
4
+ engine: string;
5
+ status: 'passed' | 'failed' | 'interrupted';
6
+ duration: number;
7
+ artifactsDir: string;
8
+ files: string[];
9
+ steps: any[];
10
+ errors: string[];
11
+ metrics: {
12
+ total: number;
13
+ passed: number;
14
+ failed: number;
15
+ skipped: number;
16
+ incomplete: number;
17
+ };
18
+ }
19
+ export declare function runNativeCI(worker: any, orchestrator: any, target: string, config: FlowstrideConfig, engine: string): Promise<NativeRunResult>;
@@ -0,0 +1,203 @@
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.runNativeCI = runNativeCI;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const projectPaths_1 = require("../utils/projectPaths");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_child_process_1 = require("node:child_process");
12
+ const promises_1 = require("node:timers/promises");
13
+ const lexer_1 = require("../parser/lexer");
14
+ const parser_1 = require("../parser/parser");
15
+ function safeName(name) { return name.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 120) + '-' + (0, node_crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 12); }
16
+ function xml(value) { return String(value).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[<>&"']/g, char => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[char])); }
17
+ async function runNativeCI(worker, orchestrator, target, config, engine) {
18
+ const started = Date.now();
19
+ const artifactRoot = config.artifactsDir;
20
+ node_fs_1.default.mkdirSync(artifactRoot, { recursive: true });
21
+ const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(artifactRoot, 'run-'));
22
+ config.artifactsDir = directory;
23
+ const steps = new Map();
24
+ const completed = new Set();
25
+ const errors = [];
26
+ const planned = new Map();
27
+ let bailed = false;
28
+ let expected = 0;
29
+ let files = [];
30
+ let server;
31
+ let interrupted = false;
32
+ const interrupt = () => { interrupted = true; worker.abort(); orchestrator.abort?.(); };
33
+ const event = (data) => {
34
+ if (!data?.payload)
35
+ return;
36
+ const p = data.payload;
37
+ if (data.type === 'BAIL')
38
+ bailed = true;
39
+ if (data.type === 'LOG_EVENT' && p.status === 'error') {
40
+ const active = [...steps.values()].reverse().find(step => step.fileName === p.fileName && step.status === 'running');
41
+ if (active)
42
+ active.error = p.details?.rawError || p.summary;
43
+ }
44
+ if (data.type === 'STEP_UPDATE' || data.type === 'STEP_ADD') {
45
+ const key = `${p.fileName}:${p.id}`;
46
+ steps.set(key, { ...steps.get(key), ...p, screenshot: undefined });
47
+ if (p.status === 'failed' && typeof p.screenshot === 'string') {
48
+ const match = p.screenshot.match(/^data:image\/(png|jpeg);base64,(.+)$/s) || (/^[A-Za-z0-9+/=\r\n]+$/.test(p.screenshot) ? ['', 'jpeg', p.screenshot] : null);
49
+ if (match)
50
+ node_fs_1.default.writeFileSync(node_path_1.default.join(directory, `${safeName(p.fileName)}-${safeName(p.id)}.${match[1] === 'jpeg' ? 'jpg' : 'png'}`), Buffer.from(match[2], 'base64'));
51
+ }
52
+ }
53
+ if (data.type === 'FILE_COMPLETE' || data.type === 'FILE_FAILED')
54
+ completed.add(p.fileName);
55
+ if (data.type === 'FILE_FAILED' && ![...steps.values()].some(step => step.fileName === p.fileName && step.status === 'failed'))
56
+ errors.push(`${p.fileName}: ${p.error || 'Execution failed'}`);
57
+ if (data.type === 'BROWSER_ERROR' && config.browserErrors !== 'record')
58
+ errors.push(`Browser: ${p.message}`);
59
+ if (data.type === 'CLOUD_SYNC')
60
+ config.cloudSyncResult = p;
61
+ if (data.type === 'WORKER_ERROR')
62
+ errors.push(p.message || 'Worker failed');
63
+ };
64
+ // Modern orchestrators forward child-process events here; the worker listener
65
+ // also supports older compatible Pro packages. Step maps make duplicates harmless.
66
+ config.onRunEvent = event;
67
+ if (orchestrator.nativeEventProtocol !== 1)
68
+ worker.on('worker_event', event);
69
+ process.on('SIGINT', interrupt);
70
+ process.on('SIGTERM', interrupt);
71
+ try {
72
+ files = (0, projectPaths_1.discoverFlows)(target, config.projectRoot || process.cwd());
73
+ config.flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
74
+ if (!files.length)
75
+ throw new Error('No .flow tests found.');
76
+ for (const file of files) {
77
+ const features = (0, projectPaths_1.selectedFeatures)(new parser_1.Parser(new lexer_1.Lexer(node_fs_1.default.readFileSync(file, 'utf8')).tokenise()).parse());
78
+ for (const feature of features)
79
+ for (const scenario of feature.scenarios)
80
+ for (const step of scenario.steps) {
81
+ const fileName = (0, projectPaths_1.flowIdentity)(file, config.flowRoot);
82
+ planned.set(`${fileName}:${step.id}`, { id: step.id, description: step.blockDescription, command: step.action, fileName, status: 'skipped', reason: 'bail' });
83
+ }
84
+ expected += features.reduce((n, feature) => n + feature.scenarios.reduce((m, scenario) => m + scenario.steps.length, 0), 0);
85
+ }
86
+ if (!expected)
87
+ throw new Error('No executable test steps found.');
88
+ if (config.webServer) {
89
+ const settings = config.webServer;
90
+ const probe = async () => { try {
91
+ return (await fetch(settings.url, { signal: AbortSignal.timeout(1000) })).ok;
92
+ }
93
+ catch {
94
+ return false;
95
+ } };
96
+ if (await probe())
97
+ throw new Error('webServer readiness URL is already serving; refusing to test an unowned server.');
98
+ const output = node_fs_1.default.openSync(node_path_1.default.join(directory, 'web-server.log'), 'a');
99
+ try {
100
+ server = (0, node_child_process_1.spawn)(settings.command, { cwd: settings.cwd, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', output, output] });
101
+ }
102
+ finally {
103
+ node_fs_1.default.closeSync(output);
104
+ }
105
+ let spawnError;
106
+ server.on('error', error => { spawnError = error; });
107
+ const deadline = Date.now() + (settings.timeout ?? 30000);
108
+ while (!(await probe())) {
109
+ if (interrupted)
110
+ throw new Error('Run interrupted during server startup.');
111
+ if (spawnError)
112
+ throw spawnError;
113
+ if (server.exitCode !== null || server.signalCode !== null)
114
+ throw new Error('webServer exited before readiness.');
115
+ if (Date.now() > deadline)
116
+ throw new Error('webServer readiness timed out.');
117
+ await (0, promises_1.setTimeout)(100);
118
+ }
119
+ }
120
+ if (interrupted)
121
+ throw new Error('Run interrupted before execution.');
122
+ const ok = await orchestrator.start(target);
123
+ if (ok === false && ![...steps.values()].some(step => step.status === 'failed'))
124
+ errors.push('Engine reported a failed run.');
125
+ }
126
+ catch (error) {
127
+ errors.push(error instanceof Error ? error.message : String(error));
128
+ }
129
+ finally {
130
+ try {
131
+ await worker.shutdown();
132
+ }
133
+ catch (error) {
134
+ errors.push(`Cleanup failed: ${String(error)}`);
135
+ }
136
+ try {
137
+ await orchestrator.close?.();
138
+ }
139
+ catch (error) {
140
+ errors.push(`Orchestrator cleanup failed: ${String(error)}`);
141
+ }
142
+ if (server?.pid) {
143
+ const stop = (signal) => { try {
144
+ process.kill(process.platform === 'win32' ? server.pid : -server.pid, signal);
145
+ }
146
+ catch (error) {
147
+ if (error.code !== 'ESRCH')
148
+ errors.push(`Server cleanup: ${error.message}`);
149
+ } };
150
+ stop('SIGTERM');
151
+ await Promise.race([new Promise(resolve => server.once('exit', () => resolve())), (0, promises_1.setTimeout)(1000)]);
152
+ // Terminate remaining descendants even if the shell exited first.
153
+ stop('SIGKILL');
154
+ }
155
+ worker.off('worker_event', event);
156
+ delete config.onRunEvent;
157
+ process.off('SIGINT', interrupt);
158
+ process.off('SIGTERM', interrupt);
159
+ }
160
+ if (interrupted)
161
+ errors.push('Run interrupted.');
162
+ if (bailed)
163
+ for (const [key, step] of planned)
164
+ if (!steps.has(key))
165
+ steps.set(key, step);
166
+ const results = [...steps.values()];
167
+ const terminal = results.filter(step => ['passed', 'failed', 'skipped', 'not_tested'].includes(step.status));
168
+ const incomplete = Math.max(0, expected - terminal.length);
169
+ for (const file of files)
170
+ if (!bailed && !completed.has((0, projectPaths_1.flowIdentity)(file, config.flowRoot)))
171
+ errors.push(`Incomplete file: ${(0, projectPaths_1.flowIdentity)(file, config.flowRoot)}`);
172
+ if (results.some(step => !['passed', 'failed', 'skipped', 'not_tested'].includes(step.status)))
173
+ errors.push('Unfinished test steps.');
174
+ if (terminal.length > expected)
175
+ errors.push('Engine produced an unexpected step count.');
176
+ const metrics = { total: expected, passed: results.filter(s => s.status === 'passed').length,
177
+ failed: results.filter(s => s.status === 'failed').length, skipped: results.filter(s => ['skipped', 'not_tested'].includes(s.status)).length, incomplete };
178
+ const result = { cloudSync: config.cloudSyncResult, protocol: 1, engine, status: interrupted ? 'interrupted' : errors.length || metrics.failed || incomplete ? 'failed' : 'passed',
179
+ duration: Date.now() - started, artifactsDir: directory, files: files.map(file => (0, projectPaths_1.flowIdentity)(file, config.flowRoot)), steps: results, errors, metrics };
180
+ // Existing workers finalize videos into these locations. Copy only run files,
181
+ // never the project directory or credentials.
182
+ if (config.recordVideo !== false)
183
+ for (const file of files) {
184
+ for (const folder of ['artifacts', 'mock-cloud']) {
185
+ const video = node_path_1.default.join(process.cwd(), '.flowstride', folder, (0, projectPaths_1.videoFileName)((0, projectPaths_1.flowIdentity)(file, config.flowRoot)));
186
+ if (node_fs_1.default.existsSync(video) && node_fs_1.default.statSync(video).mtimeMs >= started) {
187
+ node_fs_1.default.copyFileSync(video, node_path_1.default.join(directory, node_path_1.default.basename(video)));
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ config.artifactsDir = artifactRoot;
193
+ node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'summary.json'), JSON.stringify(result, null, 2));
194
+ const cases = results.map(step => `<testcase classname="${xml(step.fileName)}" name="${xml(step.description || step.id)}">${step.status === 'failed' ? `<failure message="${xml(step.error || 'Test step failed')}"/>` : ['skipped', 'not_tested'].includes(step.status) ? '<skipped/>' : ''}</testcase>`);
195
+ for (const error of errors)
196
+ cases.push(`<testcase name="execution"><error message="${xml(error)}"/></testcase>`);
197
+ if (incomplete)
198
+ cases.push(`<testcase name="completion"><failure message="${incomplete} incomplete steps"/></testcase>`);
199
+ node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'junit.xml'), `<?xml version="1.0"?><testsuite name="Flowstride" tests="${cases.length}" failures="${metrics.failed + (incomplete ? 1 : 0)}" errors="${errors.length}" skipped="${metrics.skipped}">${cases.join('')}</testsuite>`);
200
+ if (process.env.GITHUB_STEP_SUMMARY)
201
+ node_fs_1.default.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n### Flowstride · ${config.device}\nResult: **${result.status}** (${engine})\n\nPassed: ${metrics.passed}; failed: ${metrics.failed}; skipped: ${metrics.skipped}; incomplete: ${metrics.incomplete}.\n`);
202
+ return result;
203
+ }