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.
- package/NATIVE_CI.md +155 -0
- package/README.md +40 -33
- package/RELEASE_NOTES.md +40 -0
- package/dist/adapters/web/playwright.d.ts +4 -0
- package/dist/adapters/web/playwright.js +85 -38
- 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/open.d.ts +1 -0
- package/dist/cli/commands/open.js +12 -0
- package/dist/cli/commands/run.js +15 -34
- package/dist/cli/index.js +51 -5
- package/dist/cli/manual_server/workspace_server.d.ts +1 -0
- package/dist/cli/manual_server/workspace_server.js +751 -0
- 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/parser/lexer.js +4 -0
- package/dist/parser/parser.js +19 -3
- package/dist/runner/flow_orchestrator.d.ts +5 -1
- package/dist/runner/flow_orchestrator.js +31 -7
- package/dist/runner/flow_worker.js +57 -13
- 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
- package/src/ui/dist/assets/index-DIC22ZK_.js +638 -0
- package/src/ui/dist/assets/workspace_engine_bg-BVlpAZ1H.wasm +0 -0
- package/src/ui/dist/index.html +1 -1
- 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,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;
|
package/dist/ci/init.js
ADDED
|
@@ -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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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
|
+
}
|
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,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
|
|
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.
|
|
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,
|
|
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
|
|
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>;
|