flowstride 1.0.19 → 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.
- package/NATIVE_CI.md +155 -0
- package/README.md +40 -33
- package/RELEASE_NOTES.md +40 -0
- package/dist/adapters/web/playwright.d.ts +1 -0
- package/dist/adapters/web/playwright.js +18 -14
- package/dist/ci/config.d.ts +3 -0
- package/dist/ci/config.js +76 -0
- package/dist/ci/deadline.d.ts +2 -0
- package/dist/ci/deadline.js +16 -0
- package/dist/ci/init.d.ts +1 -0
- package/dist/ci/init.js +21 -0
- package/dist/ci/runtime.d.ts +19 -0
- package/dist/ci/runtime.js +190 -0
- package/dist/cli/commands/run.js +15 -34
- package/dist/cli/index.js +44 -5
- package/dist/game/bridge.d.ts +13 -0
- package/dist/game/bridge.js +15 -0
- package/dist/game/sdk.d.ts +27 -0
- package/dist/game/sdk.js +255 -0
- package/dist/game/three-bridge.d.ts +18 -0
- package/dist/game/three-bridge.js +59 -0
- package/dist/runner/flow_orchestrator.d.ts +5 -1
- package/dist/runner/flow_orchestrator.js +31 -7
- package/dist/runner/flow_worker.js +28 -4
- package/dist/runner/plugin_manager.d.ts +1 -0
- package/dist/runner/plugin_manager.js +33 -1
- package/dist/types/index.d.ts +36 -0
- package/examples/native-game/flows/canvas.flow +13 -0
- package/examples/native-game/flowstride.config.json +12 -0
- package/examples/native-game/server.mjs +12 -0
- package/package.json +14 -5
|
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
|
73
|
-
|
|
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 ?
|
|
119
|
-
if (
|
|
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,12 @@ 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
|
|
45
|
+
const init_2 = require("./commands/init");
|
|
44
46
|
const open_1 = require("./commands/open");
|
|
45
47
|
const fs = __importStar(require("fs"));
|
|
46
48
|
const path = __importStar(require("path"));
|
|
@@ -54,12 +56,12 @@ const API_BASE = process.env.FLOWSTRIDE_API_URL || "https://cloud.flowstride.io"
|
|
|
54
56
|
program
|
|
55
57
|
.name("flowstride")
|
|
56
58
|
.description("Flowstride: The Enterprise Flow-First Automation CLI")
|
|
57
|
-
.version("1.0.
|
|
59
|
+
.version("1.0.20-rc.2");
|
|
58
60
|
program
|
|
59
61
|
.command("init")
|
|
60
62
|
.description("Initialise a new Flowstride workspace in the current directory")
|
|
61
63
|
.action(async () => {
|
|
62
|
-
await (0,
|
|
64
|
+
await (0, init_2.initAction)();
|
|
63
65
|
});
|
|
64
66
|
program
|
|
65
67
|
.command("login")
|
|
@@ -271,11 +273,17 @@ program
|
|
|
271
273
|
.option("--headed", "Run browser in headed mode")
|
|
272
274
|
.option("--headless", "Run browser in headless mode")
|
|
273
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')
|
|
274
280
|
.action(async (targetPath, options) => {
|
|
275
281
|
if (typeof targetPath === "object") {
|
|
276
282
|
options = targetPath;
|
|
277
283
|
targetPath = undefined;
|
|
278
284
|
}
|
|
285
|
+
if (options.ci)
|
|
286
|
+
process.env.FLOWSTRIDE_NATIVE_CI = '1';
|
|
279
287
|
let token = process.env.FLOWSTRIDE_TOKEN || null;
|
|
280
288
|
let forceOS = false;
|
|
281
289
|
const localCredentialsPath = path.join(process.cwd(), ".flowstride", "credentials.json");
|
|
@@ -326,6 +334,19 @@ program
|
|
|
326
334
|
else if (response.ok) {
|
|
327
335
|
const data = await response.json();
|
|
328
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
|
+
}
|
|
329
350
|
if (tier === "PERSONAL") {
|
|
330
351
|
console.log(chalk_1.default.yellow(`🟡 [OS]: [${data.workspaceName}] (Tier: PERSONAL)`));
|
|
331
352
|
}
|
|
@@ -344,7 +365,7 @@ program
|
|
|
344
365
|
if (options.headed) {
|
|
345
366
|
options.headless = false;
|
|
346
367
|
}
|
|
347
|
-
const resolvedTargetPath = targetPath
|
|
368
|
+
const resolvedTargetPath = targetPath || (0, config_1.loadNativeConfig)(process.cwd(), options).testDir;
|
|
348
369
|
const strictUiFlowsDir = path.resolve(process.cwd(), resolvedTargetPath);
|
|
349
370
|
if (!fs.existsSync(strictUiFlowsDir)) {
|
|
350
371
|
console.error(`\n[ERROR]: Could not find ${strictUiFlowsDir}.`);
|
|
@@ -352,11 +373,17 @@ program
|
|
|
352
373
|
process.exit(1);
|
|
353
374
|
}
|
|
354
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
|
+
}
|
|
355
382
|
await (0, run_1.runAction)(strictUiFlowsDir, options);
|
|
356
383
|
});
|
|
357
384
|
async function handleOfflinePrompt() {
|
|
358
385
|
console.log(chalk_1.default.yellow("🛜 OFFLINE: Unable to reach Flowstride Cloud. Enterprise Sync paused."));
|
|
359
|
-
if (process.env.CI || process.env.FLOWSTRIDE_TOKEN) {
|
|
386
|
+
if (process.env.CI || process.env.FLOWSTRIDE_NATIVE_CI || process.env.FLOWSTRIDE_TOKEN) {
|
|
360
387
|
console.log(chalk_1.default.yellow("⚠️ CI/CD Environment: Automatically falling back to OS Engine."));
|
|
361
388
|
return true;
|
|
362
389
|
}
|
|
@@ -381,4 +408,16 @@ program
|
|
|
381
408
|
.action(async () => {
|
|
382
409
|
await (0, open_1.openAction)();
|
|
383
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));
|
|
384
423
|
program.parse(process.argv);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Register observable state and explicitly test-only fixtures in a browser game. */
|
|
2
|
+
export interface GameBridgeOptions {
|
|
3
|
+
readState?: () => unknown;
|
|
4
|
+
locate?: (id: string) => {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
};
|
|
8
|
+
testMode?: boolean;
|
|
9
|
+
fixtures?: Record<string, (data: unknown) => unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function registerGame(options: GameBridgeOptions): {
|
|
12
|
+
dispose(): void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerGame = registerGame;
|
|
4
|
+
function registerGame(options) {
|
|
5
|
+
if (typeof window === 'undefined')
|
|
6
|
+
throw new Error('Game bridges must be registered in the browser.');
|
|
7
|
+
if (typeof options.readState !== 'function' && typeof options.locate !== 'function')
|
|
8
|
+
throw new Error('A game bridge requires readState or locate.');
|
|
9
|
+
const bridge = { readState: options.readState, locate: options.locate,
|
|
10
|
+
testMode: options.testMode === true,
|
|
11
|
+
fixtures: options.testMode === true ? options.fixtures || {} : {} };
|
|
12
|
+
window.__FLOWSTRIDE_GAME__ = bridge;
|
|
13
|
+
return { dispose() { if (window.__FLOWSTRIDE_GAME__ === bridge)
|
|
14
|
+
delete window.__FLOWSTRIDE_GAME__; } };
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { FlowstrideConfig } from '../types';
|
|
3
|
+
export declare class GameSDK {
|
|
4
|
+
private page;
|
|
5
|
+
private config;
|
|
6
|
+
private snapshots;
|
|
7
|
+
private states;
|
|
8
|
+
constructor(page: () => Page, config: FlowstrideConfig);
|
|
9
|
+
private timeout;
|
|
10
|
+
private bridgeReady;
|
|
11
|
+
private point;
|
|
12
|
+
private touch;
|
|
13
|
+
tap(options: any): Promise<void>;
|
|
14
|
+
drag(options: any): Promise<void>;
|
|
15
|
+
swipe(options: any): Promise<void>;
|
|
16
|
+
key(options: any): Promise<void>;
|
|
17
|
+
checkpoint(options: any): Promise<string>;
|
|
18
|
+
snapshot(options: any): Promise<void>;
|
|
19
|
+
private difference;
|
|
20
|
+
expectVisual(options: any): Promise<number>;
|
|
21
|
+
state(options?: any): Promise<any>;
|
|
22
|
+
expectState(options: any): Promise<void>;
|
|
23
|
+
snapshotState(options: any): Promise<void>;
|
|
24
|
+
expectPreserved(options: any): Promise<void>;
|
|
25
|
+
fixture(options: any): Promise<any>;
|
|
26
|
+
select(options: any): Promise<void>;
|
|
27
|
+
}
|