flowstride 1.0.18 → 1.0.20-rc.2

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 (41) hide show
  1. package/NATIVE_CI.md +155 -0
  2. package/README.md +40 -33
  3. package/RELEASE_NOTES.md +40 -0
  4. package/dist/adapters/web/playwright.d.ts +4 -0
  5. package/dist/adapters/web/playwright.js +85 -38
  6. package/dist/ci/config.d.ts +3 -0
  7. package/dist/ci/config.js +76 -0
  8. package/dist/ci/deadline.d.ts +2 -0
  9. package/dist/ci/deadline.js +16 -0
  10. package/dist/ci/init.d.ts +1 -0
  11. package/dist/ci/init.js +21 -0
  12. package/dist/ci/runtime.d.ts +19 -0
  13. package/dist/ci/runtime.js +190 -0
  14. package/dist/cli/commands/open.d.ts +1 -0
  15. package/dist/cli/commands/open.js +12 -0
  16. package/dist/cli/commands/run.js +15 -34
  17. package/dist/cli/index.js +51 -5
  18. package/dist/cli/manual_server/workspace_server.d.ts +1 -0
  19. package/dist/cli/manual_server/workspace_server.js +751 -0
  20. package/dist/game/bridge.d.ts +13 -0
  21. package/dist/game/bridge.js +15 -0
  22. package/dist/game/sdk.d.ts +27 -0
  23. package/dist/game/sdk.js +255 -0
  24. package/dist/game/three-bridge.d.ts +18 -0
  25. package/dist/game/three-bridge.js +59 -0
  26. package/dist/parser/lexer.js +4 -0
  27. package/dist/parser/parser.js +19 -3
  28. package/dist/runner/flow_orchestrator.d.ts +5 -1
  29. package/dist/runner/flow_orchestrator.js +31 -7
  30. package/dist/runner/flow_worker.js +57 -13
  31. package/dist/runner/plugin_manager.d.ts +1 -0
  32. package/dist/runner/plugin_manager.js +33 -1
  33. package/dist/types/index.d.ts +36 -0
  34. package/examples/native-game/flows/canvas.flow +13 -0
  35. package/examples/native-game/flowstride.config.json +12 -0
  36. package/examples/native-game/server.mjs +12 -0
  37. package/package.json +14 -5
  38. package/src/ui/dist/assets/index-DIC22ZK_.js +638 -0
  39. package/src/ui/dist/assets/workspace_engine_bg-BVlpAZ1H.wasm +0 -0
  40. package/src/ui/dist/index.html +1 -1
  41. package/src/ui/dist/assets/index-BCdexgt5.js +0 -270
@@ -0,0 +1,76 @@
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 node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ function isCIEnvironment() {
11
+ return ['FLOWSTRIDE_NATIVE_CI', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL', 'FLOWSTRIDE_API_KEY']
12
+ .some(key => !!process.env[key] && process.env[key] !== 'false' && process.env[key] !== '0');
13
+ }
14
+ function loadNativeConfig(root, options) {
15
+ const file = node_path_1.default.join(root, options.config || 'flowstride.config.json');
16
+ let input = {};
17
+ if (node_fs_1.default.existsSync(file)) {
18
+ input = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
19
+ if (!input || typeof input !== 'object' || Array.isArray(input))
20
+ throw new Error('Flowstride configuration must be an object.');
21
+ }
22
+ else if (options.config)
23
+ throw new Error(`Configuration not found: ${file}`);
24
+ const config = { ...input, timeout: input.timeout ?? 30000, headless: input.headless ?? true,
25
+ ci: !!options.ci || isCIEnvironment(), device: options.device ?? input.device ?? 'desktop',
26
+ artifactsDir: input.artifactsDir ?? 'flowstride-artifacts', testDir: input.testDir ?? 'flows',
27
+ defaultDialogBehavior: input.defaultDialogBehavior ?? 'dismiss',
28
+ recordVideo: input.recordVideo ?? true, browserErrors: input.browserErrors ?? 'fail',
29
+ isCloudSyncActive: options.isCloudSyncActive ?? false, machineToken: options.machineToken ?? null,
30
+ workspaceId: options.workspaceId ?? null, manifest: options.manifest ?? null };
31
+ if (config.ci && input.cloudSync !== true)
32
+ config.isCloudSyncActive = false;
33
+ if (options.headless || config.ci)
34
+ config.headless = true;
35
+ else if (options.headed)
36
+ config.headless = false;
37
+ if (process.env.BASE_URL)
38
+ config.baseUrl = process.env.BASE_URL;
39
+ if (!Number.isSafeInteger(config.timeout) || config.timeout < 1)
40
+ throw new Error('timeout must be a positive integer.');
41
+ if (!['desktop', 'phone', 'landscape'].includes(config.device))
42
+ throw new Error('device must be desktop, phone, or landscape.');
43
+ for (const key of ['headless', 'recordVideo', 'bail'])
44
+ if (config[key] !== undefined && typeof config[key] !== 'boolean')
45
+ throw new Error(`${key} must be boolean.`);
46
+ if (!['fail', 'record'].includes(config.browserErrors))
47
+ throw new Error('browserErrors must be fail or record.');
48
+ for (const key of ['artifactsDir', 'testDir', 'pluginsDir']) {
49
+ if (config[key] === undefined)
50
+ continue;
51
+ if (typeof config[key] !== 'string' || !config[key].trim())
52
+ throw new Error(`${key} must be a nonempty path.`);
53
+ const resolved = node_path_1.default.resolve(root, config[key]);
54
+ const relative = node_path_1.default.relative(root, resolved);
55
+ if ((key === 'artifactsDir' && !relative) || relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
56
+ throw new Error(`${key} must be a directory inside the project.`);
57
+ config[key] = resolved;
58
+ }
59
+ if (config.viewport && (!Number.isSafeInteger(config.viewport.width) || config.viewport.width < 1 || !Number.isSafeInteger(config.viewport.height) || config.viewport.height < 1))
60
+ throw new Error('viewport requires positive integer width and height.');
61
+ if (config.webServer) {
62
+ const server = config.webServer;
63
+ if (typeof server.command !== 'string' || !server.command.trim() || typeof server.url !== 'string')
64
+ throw new Error('webServer requires command and url.');
65
+ const url = new URL(server.url);
66
+ if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
67
+ throw new Error('webServer readiness URL must use local HTTP(S).');
68
+ if (server.timeout !== undefined && (!Number.isSafeInteger(server.timeout) || server.timeout < 1))
69
+ throw new Error('webServer.timeout must be positive.');
70
+ server.cwd = node_path_1.default.resolve(root, server.cwd || '.');
71
+ const relative = node_path_1.default.relative(root, server.cwd);
72
+ if (relative === '..' || relative.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(relative))
73
+ throw new Error('webServer.cwd must be inside the project.');
74
+ }
75
+ return config;
76
+ }
@@ -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,190 @@
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_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_child_process_1 = require("node:child_process");
10
+ const promises_1 = require("node:timers/promises");
11
+ const lexer_1 = require("../parser/lexer");
12
+ const parser_1 = require("../parser/parser");
13
+ function listFlows(target) {
14
+ const stat = node_fs_1.default.statSync(target);
15
+ if (stat.isFile())
16
+ return target.endsWith('.flow') ? [target] : [];
17
+ return node_fs_1.default.readdirSync(target).filter(name => name.endsWith('.flow')).sort().map(name => node_path_1.default.join(target, name));
18
+ }
19
+ function safeName(name) { return name.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 150); }
20
+ function xml(value) { return String(value).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[<>&"']/g, char => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[char])); }
21
+ async function runNativeCI(worker, orchestrator, target, config, engine) {
22
+ const started = Date.now();
23
+ const artifactRoot = config.artifactsDir;
24
+ node_fs_1.default.mkdirSync(artifactRoot, { recursive: true });
25
+ const directory = node_fs_1.default.mkdtempSync(node_path_1.default.join(artifactRoot, 'run-'));
26
+ config.artifactsDir = directory;
27
+ const steps = new Map();
28
+ const completed = new Set();
29
+ const errors = [];
30
+ let expected = 0;
31
+ let files = [];
32
+ let server;
33
+ let interrupted = false;
34
+ const interrupt = () => { interrupted = true; worker.abort(); orchestrator.abort?.(); };
35
+ const event = (data) => {
36
+ if (!data?.payload)
37
+ return;
38
+ const p = data.payload;
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 === 'WORKER_ERROR')
60
+ errors.push(p.message || 'Worker failed');
61
+ };
62
+ // Modern orchestrators forward child-process events here; the worker listener
63
+ // also supports older compatible Pro packages. Step maps make duplicates harmless.
64
+ config.onRunEvent = event;
65
+ if (orchestrator.nativeEventProtocol !== 1)
66
+ worker.on('worker_event', event);
67
+ process.on('SIGINT', interrupt);
68
+ process.on('SIGTERM', interrupt);
69
+ try {
70
+ files = listFlows(target);
71
+ if (!files.length)
72
+ throw new Error('No .flow tests found.');
73
+ for (const file of files) {
74
+ const features = new parser_1.Parser(new lexer_1.Lexer(node_fs_1.default.readFileSync(file, 'utf8')).tokenise()).parse();
75
+ expected += features.reduce((n, feature) => n + feature.scenarios.reduce((m, scenario) => m + scenario.steps.length, 0), 0);
76
+ }
77
+ if (!expected)
78
+ throw new Error('No executable test steps found.');
79
+ if (config.webServer) {
80
+ const settings = config.webServer;
81
+ const probe = async () => { try {
82
+ return (await fetch(settings.url, { signal: AbortSignal.timeout(1000) })).ok;
83
+ }
84
+ catch {
85
+ return false;
86
+ } };
87
+ if (await probe())
88
+ throw new Error('webServer readiness URL is already serving; refusing to test an unowned server.');
89
+ const output = node_fs_1.default.openSync(node_path_1.default.join(directory, 'web-server.log'), 'a');
90
+ try {
91
+ server = (0, node_child_process_1.spawn)(settings.command, { cwd: settings.cwd, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', output, output] });
92
+ }
93
+ finally {
94
+ node_fs_1.default.closeSync(output);
95
+ }
96
+ let spawnError;
97
+ server.on('error', error => { spawnError = error; });
98
+ const deadline = Date.now() + (settings.timeout ?? 30000);
99
+ while (!(await probe())) {
100
+ if (interrupted)
101
+ throw new Error('Run interrupted during server startup.');
102
+ if (spawnError)
103
+ throw spawnError;
104
+ if (server.exitCode !== null || server.signalCode !== null)
105
+ throw new Error('webServer exited before readiness.');
106
+ if (Date.now() > deadline)
107
+ throw new Error('webServer readiness timed out.');
108
+ await (0, promises_1.setTimeout)(100);
109
+ }
110
+ }
111
+ if (interrupted)
112
+ throw new Error('Run interrupted before execution.');
113
+ const ok = await orchestrator.start(target);
114
+ if (ok === false && ![...steps.values()].some(step => step.status === 'failed'))
115
+ errors.push('Engine reported a failed run.');
116
+ }
117
+ catch (error) {
118
+ errors.push(error instanceof Error ? error.message : String(error));
119
+ }
120
+ finally {
121
+ try {
122
+ await worker.shutdown();
123
+ }
124
+ catch (error) {
125
+ errors.push(`Cleanup failed: ${String(error)}`);
126
+ }
127
+ try {
128
+ await orchestrator.close?.();
129
+ }
130
+ catch (error) {
131
+ errors.push(`Orchestrator cleanup failed: ${String(error)}`);
132
+ }
133
+ if (server?.pid) {
134
+ const stop = (signal) => { try {
135
+ process.kill(process.platform === 'win32' ? server.pid : -server.pid, signal);
136
+ }
137
+ catch (error) {
138
+ if (error.code !== 'ESRCH')
139
+ errors.push(`Server cleanup: ${error.message}`);
140
+ } };
141
+ stop('SIGTERM');
142
+ await Promise.race([new Promise(resolve => server.once('exit', () => resolve())), (0, promises_1.setTimeout)(1000)]);
143
+ // Terminate remaining descendants even if the shell exited first.
144
+ stop('SIGKILL');
145
+ }
146
+ worker.off('worker_event', event);
147
+ delete config.onRunEvent;
148
+ process.off('SIGINT', interrupt);
149
+ process.off('SIGTERM', interrupt);
150
+ }
151
+ if (interrupted)
152
+ errors.push('Run interrupted.');
153
+ const results = [...steps.values()];
154
+ const terminal = results.filter(step => ['passed', 'failed', 'skipped', 'not_tested'].includes(step.status));
155
+ const incomplete = Math.max(0, expected - terminal.length);
156
+ for (const file of files)
157
+ if (!completed.has(node_path_1.default.basename(file)))
158
+ errors.push(`Incomplete file: ${node_path_1.default.basename(file)}`);
159
+ if (results.some(step => !['passed', 'failed', 'skipped', 'not_tested'].includes(step.status)))
160
+ errors.push('Unfinished test steps.');
161
+ if (terminal.length > expected)
162
+ errors.push('Engine produced an unexpected step count.');
163
+ const metrics = { total: expected, passed: results.filter(s => s.status === 'passed').length,
164
+ failed: results.filter(s => s.status === 'failed').length, skipped: results.filter(s => ['skipped', 'not_tested'].includes(s.status)).length, incomplete };
165
+ const result = { protocol: 1, engine, status: interrupted ? 'interrupted' : errors.length || metrics.failed || incomplete ? 'failed' : 'passed',
166
+ duration: Date.now() - started, artifactsDir: directory, files: files.map(file => node_path_1.default.basename(file)), steps: results, errors, metrics };
167
+ // Existing workers finalize videos into these locations. Copy only run files,
168
+ // never the project directory or credentials.
169
+ if (config.recordVideo !== false)
170
+ for (const file of files) {
171
+ for (const folder of ['artifacts', 'mock-cloud']) {
172
+ const video = node_path_1.default.join(process.cwd(), '.flowstride', folder, node_path_1.default.basename(file, '.flow') + '.webm');
173
+ if (node_fs_1.default.existsSync(video) && node_fs_1.default.statSync(video).mtimeMs >= started) {
174
+ node_fs_1.default.copyFileSync(video, node_path_1.default.join(directory, node_path_1.default.basename(video)));
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ config.artifactsDir = artifactRoot;
180
+ node_fs_1.default.writeFileSync(node_path_1.default.join(artifactRoot, 'summary.json'), JSON.stringify(result, null, 2));
181
+ 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>`);
182
+ for (const error of errors)
183
+ cases.push(`<testcase name="execution"><error message="${xml(error)}"/></testcase>`);
184
+ if (incomplete)
185
+ cases.push(`<testcase name="completion"><failure message="${incomplete} incomplete steps"/></testcase>`);
186
+ 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>`);
187
+ if (process.env.GITHUB_STEP_SUMMARY)
188
+ 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`);
189
+ return result;
190
+ }
@@ -0,0 +1 @@
1
+ export declare function openAction(): Promise<void>;
@@ -0,0 +1,12 @@
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.openAction = openAction;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const workspace_server_1 = require("../manual_server/workspace_server");
9
+ async function openAction() {
10
+ console.log(chalk_1.default.cyan("\n🎨 Flowstride: Launching Local Manual Test Design Workspace..."));
11
+ await (0, workspace_server_1.startManualWorkspaceServer)();
12
+ }
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runAction = runAction;
37
+ const config_1 = require("../../ci/config");
38
+ const runtime_1 = require("../../ci/runtime");
37
39
  const fs = __importStar(require("fs"));
38
40
  const path = __importStar(require("path"));
39
41
  const dotenv = __importStar(require("dotenv"));
@@ -69,12 +71,8 @@ async function runAction(targetPath, options) {
69
71
  console.log(`[Environment]: Loaded default .env file`);
70
72
  }
71
73
  }
72
- const isCI = !!(process.env.CI ||
73
- process.env.GITHUB_ACTIONS ||
74
- process.env.GITLAB_CI ||
75
- process.env.CIRCLECI ||
76
- process.env.JENKINS_URL ||
77
- process.env.FLOWSTRIDE_API_KEY);
74
+ const config = (0, config_1.loadNativeConfig)(root, options);
75
+ const isCI = !!config.ci;
78
76
  if (isCI) {
79
77
  console.log("\n[CI/CD]: Environment detected. Engaging Smart Automation Behaviors...");
80
78
  }
@@ -84,39 +82,13 @@ async function runAction(targetPath, options) {
84
82
  process.exit(1);
85
83
  }
86
84
  console.log(`\n[Flowstride]: Starting execution suite at "${resolvedPath}"...`);
87
- const configPath = path.resolve(root, "flowstride.config.json");
88
- let localConfig = {};
89
- if (fs.existsSync(configPath)) {
90
- try {
91
- const fileContent = fs.readFileSync(configPath, "utf-8");
92
- localConfig = JSON.parse(fileContent);
93
- console.log(`[Config]: Loaded project configuration from flowstride.config.json`);
94
- }
95
- catch (e) {
96
- console.warn(`[Warning]: Could not parse flowstride.config.json: ${e.message}`);
97
- }
98
- }
99
- const config = {
100
- headless: isCI
101
- ? true
102
- : options.headed
103
- ? false
104
- : (localConfig.headless ?? true),
105
- timeout: localConfig.timeout ?? 30000,
106
- bail: options.bail ?? localConfig.bail ?? false,
107
- baseUrl: process.env.BASE_URL ?? localConfig.baseUrl,
108
- defaultDialogBehavior: isCI
109
- ? "dismiss"
110
- : (localConfig.defaultDialogBehavior ?? "dismiss"),
111
- pluginsDir: localConfig.pluginsDir,
112
- };
113
85
  if (isCI) {
114
86
  console.log(` > Headless Mode: ${config.headless ? "Forced ON" : "OFF"}`);
115
87
  console.log(` > Dialog Protection: ${(config.defaultDialogBehavior || "dismiss").toUpperCase()}`);
116
88
  console.log(` > Artifact Auto-Staging: ENABLED\n`);
117
89
  }
118
- let workerCount = options.workers ? parseInt(options.workers, 10) : 1;
119
- if (isNaN(workerCount) || workerCount < 1) {
90
+ let workerCount = options.workers ? Number(options.workers) : 1;
91
+ if (!Number.isSafeInteger(workerCount) || workerCount < 1) {
120
92
  console.error(`\n[Error]: Invalid worker count "${options.workers}". Must be an integer greater than 0.`);
121
93
  process.exit(1);
122
94
  }
@@ -126,6 +98,8 @@ async function runAction(targetPath, options) {
126
98
  console.log(`[Warning]: Auto-capping to ${maxSafeWorkers} workers to prevent system thrashing and crashes.\n`);
127
99
  workerCount = maxSafeWorkers;
128
100
  }
101
+ if (config.manifest?.maxWorkers !== undefined && config.manifest.maxWorkers !== 'unlimited')
102
+ workerCount = Math.min(workerCount, config.manifest.maxWorkers);
129
103
  const web = new playwright_1.WebAdapter(config);
130
104
  const api = new undici_1.ApiAdapter(config);
131
105
  const store = new temporary_store_1.TemporaryStore();
@@ -144,6 +118,8 @@ async function runAction(targetPath, options) {
144
118
  try {
145
119
  const proWorkerModule = await import((0, url_1.pathToFileURL)(proWorkerPath).href);
146
120
  const proOrchModule = await import((0, url_1.pathToFileURL)(proOrchPath).href);
121
+ if (config.ci && proOrchModule.FlowOrchestrator.nativeCIProtocol !== 1)
122
+ throw new Error('Installed Pro engine does not support native CI protocol 1; install the matching Pro candidate.');
147
123
  ActiveFlowWorker = proWorkerModule.FlowWorker;
148
124
  ActiveFlowOrchestrator = proOrchModule.FlowOrchestrator;
149
125
  engineMode = "Enterprise (Pro)";
@@ -155,6 +131,11 @@ async function runAction(targetPath, options) {
155
131
  console.log(`\n[Engine]: Booting Flowstride in ${engineMode} mode...`);
156
132
  const worker = new ActiveFlowWorker(config, web, api, store, sessions, pluginManager);
157
133
  const orchestrator = new ActiveFlowOrchestrator(worker, workerCount, config);
134
+ if (isCI) {
135
+ const result = await (0, runtime_1.runNativeCI)(worker, orchestrator, resolvedPath, config, engineMode);
136
+ console.log(`[Flowstride]: ${result.status}; reports: ${config.artifactsDir}`);
137
+ process.exit(result.status === 'passed' ? 0 : result.status === 'interrupted' ? 130 : 1);
138
+ }
158
139
  try {
159
140
  await orchestrator.start(resolvedPath);
160
141
  console.log("\n[Flowstride]: Execution suite completed successfully.");
package/dist/cli/index.js CHANGED
@@ -37,10 +37,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ const config_1 = require("../ci/config");
41
+ const init_1 = require("../ci/init");
40
42
  const commander_1 = require("commander");
41
43
  const run_1 = require("./commands/run");
42
44
  const report_1 = require("./commands/report");
43
- const init_1 = require("./commands/init");
45
+ const init_2 = require("./commands/init");
46
+ const open_1 = require("./commands/open");
44
47
  const fs = __importStar(require("fs"));
45
48
  const path = __importStar(require("path"));
46
49
  const os = __importStar(require("os"));
@@ -53,12 +56,12 @@ const API_BASE = process.env.FLOWSTRIDE_API_URL || "https://cloud.flowstride.io"
53
56
  program
54
57
  .name("flowstride")
55
58
  .description("Flowstride: The Enterprise Flow-First Automation CLI")
56
- .version("1.0.18");
59
+ .version("1.0.20-rc.2");
57
60
  program
58
61
  .command("init")
59
62
  .description("Initialise a new Flowstride workspace in the current directory")
60
63
  .action(async () => {
61
- await (0, init_1.initAction)();
64
+ await (0, init_2.initAction)();
62
65
  });
63
66
  program
64
67
  .command("login")
@@ -270,11 +273,17 @@ program
270
273
  .option("--headed", "Run browser in headed mode")
271
274
  .option("--headless", "Run browser in headless mode")
272
275
  .option("--workers <number>", "Number of parallel workers to spawn (Pro feature)")
276
+ .option('--ci', 'Run without a dashboard and write CI reports')
277
+ .option('--device <profile>', 'desktop, phone, or landscape')
278
+ .option('--config <file>', 'Path to the Flowstride configuration')
279
+ .option('--env <name>', 'Environment profile')
273
280
  .action(async (targetPath, options) => {
274
281
  if (typeof targetPath === "object") {
275
282
  options = targetPath;
276
283
  targetPath = undefined;
277
284
  }
285
+ if (options.ci)
286
+ process.env.FLOWSTRIDE_NATIVE_CI = '1';
278
287
  let token = process.env.FLOWSTRIDE_TOKEN || null;
279
288
  let forceOS = false;
280
289
  const localCredentialsPath = path.join(process.cwd(), ".flowstride", "credentials.json");
@@ -325,6 +334,19 @@ program
325
334
  else if (response.ok) {
326
335
  const data = await response.json();
327
336
  const tier = data.tier.toUpperCase();
337
+ if ((0, config_1.loadNativeConfig)(process.cwd(), options).ci && fs.existsSync(nodeModulesPath)) {
338
+ const workspaceResponse = await fetch(`${API_BASE}/api/workspace/status`, {
339
+ headers: { Authorization: `Bearer ${token}` },
340
+ });
341
+ if (!workspaceResponse.ok)
342
+ forceOS = await handleOfflinePrompt();
343
+ else {
344
+ const workspaceData = await workspaceResponse.json();
345
+ options.manifest = workspaceData.manifest;
346
+ options.workspaceId = workspaceData.workspace?._id;
347
+ options.isCloudSyncActive = true;
348
+ }
349
+ }
328
350
  if (tier === "PERSONAL") {
329
351
  console.log(chalk_1.default.yellow(`🟡 [OS]: [${data.workspaceName}] (Tier: PERSONAL)`));
330
352
  }
@@ -343,7 +365,7 @@ program
343
365
  if (options.headed) {
344
366
  options.headless = false;
345
367
  }
346
- const resolvedTargetPath = targetPath ? targetPath : "./flows";
368
+ const resolvedTargetPath = targetPath || (0, config_1.loadNativeConfig)(process.cwd(), options).testDir;
347
369
  const strictUiFlowsDir = path.resolve(process.cwd(), resolvedTargetPath);
348
370
  if (!fs.existsSync(strictUiFlowsDir)) {
349
371
  console.error(`\n[ERROR]: Could not find ${strictUiFlowsDir}.`);
@@ -351,11 +373,17 @@ program
351
373
  process.exit(1);
352
374
  }
353
375
  options.forceOS = forceOS;
376
+ if (!forceOS && token) {
377
+ options.machineToken = token;
378
+ const file = fs.existsSync(localCredentialsPath) ? localCredentialsPath : globalCredentialsPath;
379
+ if (!options.workspaceId && fs.existsSync(file))
380
+ options.workspaceId = JSON.parse(fs.readFileSync(file, 'utf8')).workspaceId;
381
+ }
354
382
  await (0, run_1.runAction)(strictUiFlowsDir, options);
355
383
  });
356
384
  async function handleOfflinePrompt() {
357
385
  console.log(chalk_1.default.yellow("🛜 OFFLINE: Unable to reach Flowstride Cloud. Enterprise Sync paused."));
358
- if (process.env.CI || process.env.FLOWSTRIDE_TOKEN) {
386
+ if (process.env.CI || process.env.FLOWSTRIDE_NATIVE_CI || process.env.FLOWSTRIDE_TOKEN) {
359
387
  console.log(chalk_1.default.yellow("⚠️ CI/CD Environment: Automatically falling back to OS Engine."));
360
388
  return true;
361
389
  }
@@ -374,4 +402,22 @@ program
374
402
  .action(async () => {
375
403
  await (0, report_1.reportAction)();
376
404
  });
405
+ program
406
+ .command("open")
407
+ .description("Launch the local manual QA UI Workspace")
408
+ .action(async () => {
409
+ await (0, open_1.openAction)();
410
+ });
411
+ program.command('install')
412
+ .description('Install Chromium matching this Flowstride engine')
413
+ .option('--with-deps', 'Install operating-system browser dependencies')
414
+ .action((options) => {
415
+ const { execFileSync } = require('node:child_process');
416
+ execFileSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', ...(options.withDeps ? ['--with-deps'] : []), 'chromium'], { stdio: 'inherit', env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_GC: '1' } });
417
+ });
418
+ program.command('ci')
419
+ .description('Set up native Flowstride CI')
420
+ .command('init <provider>')
421
+ .description('Generate a GitHub Actions workflow without overwriting existing files')
422
+ .action((provider) => (0, init_1.initCI)(provider));
377
423
  program.parse(process.argv);
@@ -0,0 +1 @@
1
+ export declare function startManualWorkspaceServer(): Promise<void>;