flowstride 1.0.14 → 1.0.16

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/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  Flowstride OS combines the human-readable structure of Behavior-Driven Development (BDD) with the raw execution power of Playwright. It allows QA engineers and developers to write robust, maintainable UI and API tests using an intuitive domain-specific language (DSL), while providing a rich, interactive React dashboard to monitor, debug, and replay test runs in real time.
6
6
 
7
+ [Documentation](https://docs.flowstride.io/)
8
+
7
9
  ---
8
10
 
9
11
  ## Why Flowstride?
@@ -97,7 +97,11 @@ async function runAction(targetPath, options) {
97
97
  }
98
98
  }
99
99
  const config = {
100
- headless: isCI ? true : (options.headless ?? localConfig.headless ?? true),
100
+ headless: isCI
101
+ ? true
102
+ : options.headed
103
+ ? false
104
+ : (localConfig.headless ?? true),
101
105
  timeout: localConfig.timeout ?? 30000,
102
106
  bail: options.bail ?? localConfig.bail ?? false,
103
107
  baseUrl: process.env.BASE_URL ?? localConfig.baseUrl,
@@ -129,15 +133,11 @@ async function runAction(targetPath, options) {
129
133
  const pluginManager = new plugin_manager_1.PluginManager(config, web, store, apiProxy);
130
134
  const vault = new persistent_vault_1.PersistentVault(root);
131
135
  const sessions = new manager_1.SessionManager(web, api, vault);
132
- // ==========================================
133
- // DYNAMIC ENGINE ROUTER (The Plugin Handoff)
134
- // ==========================================
135
136
  let ActiveFlowWorker = flow_worker_1.FlowWorker;
136
137
  let ActiveFlowOrchestrator = flow_orchestrator_1.FlowOrchestrator;
137
138
  let engineMode = "Open Source";
138
139
  const proWorkerPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro", "dist", "runner", "flow_worker.js");
139
140
  const proOrchPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro", "dist", "runner", "flow_orchestrator.js");
140
- // We only attempt to load the Pro Engine if the DRM Bouncer (index.ts) did NOT force OS Mode
141
141
  if (!options.forceOS &&
142
142
  fs.existsSync(proWorkerPath) &&
143
143
  fs.existsSync(proOrchPath)) {
@@ -153,7 +153,6 @@ async function runAction(targetPath, options) {
153
153
  }
154
154
  }
155
155
  console.log(`\n[Engine]: Booting Flowstride in ${engineMode} mode...`);
156
- // ==========================================
157
156
  const worker = new ActiveFlowWorker(config, web, api, store, sessions, pluginManager);
158
157
  const orchestrator = new ActiveFlowOrchestrator(worker, workerCount, config);
159
158
  try {
package/dist/cli/index.js CHANGED
@@ -54,7 +54,7 @@ const API_BASE = process.env.FLOWSTRIDE_API_URL || "https://cloud.flowstride.io"
54
54
  program
55
55
  .name("flowstride")
56
56
  .description("Flowstride: The Enterprise Flow-First Automation CLI")
57
- .version("1.0.14");
57
+ .version("1.0.16");
58
58
  program
59
59
  .command("init")
60
60
  .description("Initialise a new Flowstride workspace in the current directory")
@@ -64,33 +64,40 @@ program
64
64
  program
65
65
  .command("login")
66
66
  .description("Authenticate this machine with Flowstride Enterprise Cloud")
67
- .action(async () => {
67
+ .option("-t, --token <token>", "Enterprise Developer Token")
68
+ .option("-g, --global", "Save the token globally for all projects on this machine", false)
69
+ .action(async (options) => {
68
70
  const localPackageJsonPath = path.join(process.cwd(), "package.json");
69
- if (!fs.existsSync(localPackageJsonPath)) {
70
- console.error(chalk_1.default.red("✖ Error: Please navigate to a valid Flowstride project directory before logging in."));
71
- process.exit(1);
72
- }
73
- const pkg = JSON.parse(fs.readFileSync(localPackageJsonPath, "utf8"));
74
- const deps = {
75
- ...(pkg.dependencies || {}),
76
- ...(pkg.devDependencies || {}),
77
- };
78
- if (!deps["flowstride"] && !deps["flowstride-os"]) {
79
- console.error(chalk_1.default.red("✖ Error: Please navigate to a valid Flowstride project directory before logging in."));
80
- process.exit(1);
71
+ // Enforce valid project directory ONLY if doing a local login
72
+ if (!options.global) {
73
+ if (!fs.existsSync(localPackageJsonPath)) {
74
+ console.error(chalk_1.default.red("✖ Error: Please navigate to a valid Flowstride project directory before logging in locally, or use --global."));
75
+ process.exit(1);
76
+ }
77
+ const pkg = JSON.parse(fs.readFileSync(localPackageJsonPath, "utf8"));
78
+ const deps = {
79
+ ...(pkg.dependencies || {}),
80
+ ...(pkg.devDependencies || {}),
81
+ };
82
+ if (!deps["flowstride"] && !deps["flowstride-os"]) {
83
+ console.error(chalk_1.default.red("✖ Error: Please navigate to a valid Flowstride project directory before logging in locally, or use --global."));
84
+ process.exit(1);
85
+ }
81
86
  }
82
87
  console.log(chalk_1.default.bold.blue("\n🌐 Flowstride Enterprise Cloud Sync\n"));
83
- let authHeader = "";
84
- if (process.env.FLOWSTRIDE_API_KEY) {
85
- console.log(chalk_1.default.yellow("🤖 CI/CD Environment Detected. Authenticating via API Key..."));
86
- authHeader = `Bearer ${process.env.FLOWSTRIDE_API_KEY}`;
88
+ let token = process.env.FLOWSTRIDE_TOKEN || options.token;
89
+ if (process.env.FLOWSTRIDE_TOKEN) {
90
+ console.log(chalk_1.default.yellow("🤖 CI/CD Environment Detected. Authenticating via FLOWSTRIDE_TOKEN..."));
87
91
  }
88
- else {
89
- const userEmail = await (0, prompts_1.input)({
90
- message: "Enter your Flowstride email:",
92
+ else if (!token) {
93
+ token = await (0, prompts_1.password)({
94
+ message: "Paste your Enterprise Developer Token:",
95
+ mask: "*",
91
96
  });
92
- const userPass = await (0, prompts_1.password)({ message: "Enter your password:" });
93
- authHeader = `Basic ${Buffer.from(`${userEmail}:${userPass}`).toString("base64")}`;
97
+ }
98
+ if (!token || token.trim().length === 0) {
99
+ console.error(chalk_1.default.red("✖ Error: A valid Developer Token is required."));
100
+ process.exit(1);
94
101
  }
95
102
  const deviceName = os.hostname();
96
103
  const spinner = (0, ora_1.default)("Authenticating with Cloud...").start();
@@ -99,34 +106,37 @@ program
99
106
  method: "POST",
100
107
  headers: {
101
108
  "Content-Type": "application/json",
102
- Authorization: authHeader,
109
+ Authorization: `Bearer ${token.trim()}`,
103
110
  },
104
111
  body: JSON.stringify({ deviceName }),
105
112
  });
106
113
  const data = await response.json();
107
114
  if (!response.ok) {
108
- spinner.fail(chalk_1.default.red(`Authentication Failed: ${data.error || "Unknown error"}`));
115
+ spinner.fail(chalk_1.default.red(`Authentication Failed: ${data.error || "Invalid token or network error"}`));
109
116
  process.exit(1);
110
117
  }
111
118
  spinner.succeed(chalk_1.default.green("Authentication Successful!"));
112
- const configDir = path.join(process.cwd(), ".flowstride", "login");
119
+ const configDir = options.global
120
+ ? path.join(os.homedir(), ".flowstride")
121
+ : path.join(process.cwd(), ".flowstride");
113
122
  if (!fs.existsSync(configDir)) {
114
123
  fs.mkdirSync(configDir, { recursive: true });
115
124
  }
116
- const credentialsPath = path.join(configDir, "login.json");
125
+ const credentialsPath = path.join(configDir, "credentials.json");
117
126
  fs.writeFileSync(credentialsPath, JSON.stringify({
118
- machineToken: data.machineToken,
127
+ machineToken: data.machineToken || token.trim(),
119
128
  workspaceId: data.workspaceId,
120
129
  deviceName: deviceName,
121
130
  syncedAt: new Date().toISOString(),
122
131
  }, null, 2));
123
- console.log(chalk_1.default.gray(`\nCredentials securely scoped to ${credentialsPath}`));
132
+ const scopeText = options.global ? "GLOBALLY" : "LOCALLY to this project";
133
+ console.log(chalk_1.default.gray(`\nCredentials securely scoped ${scopeText} at: ${credentialsPath}`));
124
134
  if (data.isProEligible) {
125
135
  console.log(chalk_1.default.yellow(`\n⚙️ System Check: You have access to Enterprise features (AI Healing, Parallel Workers).`));
126
136
  const nodeModulesPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro");
127
137
  if (!fs.existsSync(nodeModulesPath)) {
128
138
  let installPro = true;
129
- if (!process.env.FLOWSTRIDE_API_KEY && !process.env.CI) {
139
+ if (!process.env.FLOWSTRIDE_TOKEN && !process.env.CI) {
130
140
  installPro = await (0, prompts_1.confirm)({
131
141
  message: "The Pro Engine is not installed in this project. Would you like Flowstride to automatically install it now?",
132
142
  default: true,
@@ -138,7 +148,7 @@ program
138
148
  try {
139
149
  const tarballResponse = await fetch(`${API_BASE}/api/workspace/download-pro`, {
140
150
  method: "GET",
141
- headers: { Authorization: `Bearer ${data.machineToken}` },
151
+ headers: { Authorization: `Bearer ${token.trim()}` },
142
152
  });
143
153
  if (!tarballResponse.ok) {
144
154
  throw new Error(`Secure proxy rejected request: ${tarballResponse.statusText}`);
@@ -200,21 +210,34 @@ program
200
210
  .command("status")
201
211
  .description("Display the current Flowstride CLI authentication and engine tier")
202
212
  .action(async () => {
203
- let token = process.env.FLOWSTRIDE_API_KEY || null;
204
- const credentialsPath = path.join(process.cwd(), ".flowstride", "login", "login.json");
205
- const nodeModulesPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro");
206
- if (!token && fs.existsSync(credentialsPath)) {
207
- try {
208
- const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
209
- token = credentials.machineToken;
213
+ let token = process.env.FLOWSTRIDE_TOKEN || null;
214
+ const localCredentialsPath = path.join(process.cwd(), ".flowstride", "credentials.json");
215
+ const globalCredentialsPath = path.join(os.homedir(), ".flowstride", "credentials.json");
216
+ let activeCredentialsPath = localCredentialsPath; // Default for cleanup
217
+ if (!token) {
218
+ if (fs.existsSync(localCredentialsPath)) {
219
+ activeCredentialsPath = localCredentialsPath;
220
+ try {
221
+ const credentials = JSON.parse(fs.readFileSync(localCredentialsPath, "utf8"));
222
+ token = credentials.machineToken;
223
+ }
224
+ catch (err) { }
225
+ }
226
+ else if (fs.existsSync(globalCredentialsPath)) {
227
+ activeCredentialsPath = globalCredentialsPath;
228
+ try {
229
+ const credentials = JSON.parse(fs.readFileSync(globalCredentialsPath, "utf8"));
230
+ token = credentials.machineToken;
231
+ }
232
+ catch (err) { }
210
233
  }
211
- catch (err) { }
212
234
  }
213
- if (!process.env.FLOWSTRIDE_API_KEY &&
235
+ const nodeModulesPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro");
236
+ if (!process.env.FLOWSTRIDE_TOKEN &&
214
237
  token &&
215
238
  !fs.existsSync(nodeModulesPath)) {
216
- if (fs.existsSync(credentialsPath))
217
- fs.unlinkSync(credentialsPath);
239
+ if (fs.existsSync(activeCredentialsPath))
240
+ fs.unlinkSync(activeCredentialsPath);
218
241
  token = null;
219
242
  }
220
243
  if (!token) {
@@ -228,8 +251,8 @@ program
228
251
  });
229
252
  if (response.status === 401) {
230
253
  console.log(chalk_1.default.red("🔴 AUTH EXPIRED: Your device session was revoked or expired."));
231
- if (fs.existsSync(credentialsPath))
232
- fs.unlinkSync(credentialsPath);
254
+ if (fs.existsSync(activeCredentialsPath))
255
+ fs.unlinkSync(activeCredentialsPath);
233
256
  }
234
257
  else if (response.ok) {
235
258
  const data = await response.json();
@@ -253,30 +276,43 @@ program
253
276
  .command("run [targetPath]")
254
277
  .description("Execute all .flow automation journeys in the ./flows directory")
255
278
  .option("-b, --bail", "Stop execution on first failure", false)
256
- .option("--headed", "Run browser in headed mode", false)
257
- .option("--headless <boolean>", "Run browser in headless mode", (val) => val === "true", true)
279
+ .option("--headed", "Run browser in headed mode")
280
+ .option("--headless", "Run browser in headless mode")
258
281
  .option("--workers <number>", "Number of parallel workers to spawn (Pro feature)")
259
282
  .action(async (targetPath, options) => {
260
283
  if (typeof targetPath === "object") {
261
284
  options = targetPath;
262
285
  targetPath = undefined;
263
286
  }
264
- let token = process.env.FLOWSTRIDE_API_KEY || null;
287
+ let token = process.env.FLOWSTRIDE_TOKEN || null;
265
288
  let forceOS = false;
266
- const credentialsPath = path.join(process.cwd(), ".flowstride", "login", "login.json");
267
- const nodeModulesPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro");
268
- if (!token && fs.existsSync(credentialsPath)) {
269
- try {
270
- const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
271
- token = credentials.machineToken;
289
+ const localCredentialsPath = path.join(process.cwd(), ".flowstride", "credentials.json");
290
+ const globalCredentialsPath = path.join(os.homedir(), ".flowstride", "credentials.json");
291
+ let activeCredentialsPath = localCredentialsPath;
292
+ if (!token) {
293
+ if (fs.existsSync(localCredentialsPath)) {
294
+ activeCredentialsPath = localCredentialsPath;
295
+ try {
296
+ const credentials = JSON.parse(fs.readFileSync(localCredentialsPath, "utf8"));
297
+ token = credentials.machineToken;
298
+ }
299
+ catch (err) { }
300
+ }
301
+ else if (fs.existsSync(globalCredentialsPath)) {
302
+ activeCredentialsPath = globalCredentialsPath;
303
+ try {
304
+ const credentials = JSON.parse(fs.readFileSync(globalCredentialsPath, "utf8"));
305
+ token = credentials.machineToken;
306
+ }
307
+ catch (err) { }
272
308
  }
273
- catch (err) { }
274
309
  }
275
- if (!process.env.FLOWSTRIDE_API_KEY &&
310
+ const nodeModulesPath = path.join(process.cwd(), "node_modules", "@flowstride", "pro");
311
+ if (!process.env.FLOWSTRIDE_TOKEN &&
276
312
  token &&
277
313
  !fs.existsSync(nodeModulesPath)) {
278
- if (fs.existsSync(credentialsPath))
279
- fs.unlinkSync(credentialsPath);
314
+ if (fs.existsSync(activeCredentialsPath))
315
+ fs.unlinkSync(activeCredentialsPath);
280
316
  token = null;
281
317
  }
282
318
  if (!token) {
@@ -291,8 +327,8 @@ program
291
327
  });
292
328
  if (response.status === 401) {
293
329
  console.log(chalk_1.default.red("🔴 AUTH EXPIRED: Your device session was revoked or expired."));
294
- if (fs.existsSync(credentialsPath))
295
- fs.unlinkSync(credentialsPath);
330
+ if (fs.existsSync(activeCredentialsPath))
331
+ fs.unlinkSync(activeCredentialsPath);
296
332
  forceOS = true;
297
333
  }
298
334
  else if (response.ok) {
@@ -328,7 +364,7 @@ program
328
364
  });
329
365
  async function handleOfflinePrompt() {
330
366
  console.log(chalk_1.default.yellow("🛜 OFFLINE: Unable to reach Flowstride Cloud. Enterprise Sync paused."));
331
- if (process.env.CI || process.env.FLOWSTRIDE_API_KEY) {
367
+ if (process.env.CI || process.env.FLOWSTRIDE_TOKEN) {
332
368
  console.log(chalk_1.default.yellow("⚠️ CI/CD Environment: Automatically falling back to OS Engine."));
333
369
  return true;
334
370
  }
@@ -615,9 +615,28 @@ class Parser {
615
615
  throw new Error(`Syntax Error on line ${targetToken.line}: Invalid keyword '${target}'. Flowstride uses strict camelCase. Did you mean 'reqHeader'?`);
616
616
  }
617
617
  if (target === "reqHeader") {
618
- const hKey = this.consume(lexer_1.TokenType.STRING, "Header key expected").value;
619
- const hVal = this.consume(lexer_1.TokenType.STRING, "Header value expected").value;
620
- payload.headers.push({ key: hKey, value: hVal });
618
+ const firstToken = this.consume(lexer_1.TokenType.STRING, "Header key or JSON block expected");
619
+ if (!this.isAtEnd() && this.peek().type === lexer_1.TokenType.STRING) {
620
+ const hKey = firstToken.value;
621
+ const hVal = this.advance().value;
622
+ payload.headers.push({ key: hKey, value: hVal });
623
+ }
624
+ else {
625
+ try {
626
+ const headerObj = JSON.parse(firstToken.value);
627
+ if (typeof headerObj !== "object" ||
628
+ headerObj === null ||
629
+ Array.isArray(headerObj)) {
630
+ throw new Error("Parsed JSON is not a Key-Value object.");
631
+ }
632
+ for (const [k, v] of Object.entries(headerObj)) {
633
+ payload.headers.push({ key: k, value: String(v) });
634
+ }
635
+ }
636
+ catch (e) {
637
+ throw new Error(`Line ${firstToken.line}: Invalid JSON format for reqHeader block. Ensure it is a valid JSON object, or use the legacy format with two strings (Key and Value).`);
638
+ }
639
+ }
621
640
  }
622
641
  else if (target === "reqBody") {
623
642
  payload.body = this.consume(lexer_1.TokenType.STRING, "Body content expected").value;
@@ -276,22 +276,41 @@ class FlowOrchestrator {
276
276
  }
277
277
  });
278
278
  });
279
- this.httpServer.listen(0, async () => {
280
- const address = this.httpServer?.address();
281
- const port = typeof address === "object" && address !== null ? address.port : null;
282
- if (!port) {
283
- console.error(chalk_1.default.red("\n[Error]: Could not bind to an available port."));
284
- process.exit(1);
285
- }
286
- const localUrl = `http://localhost:${port}`;
287
- const { default: open } = await import("open");
288
- await open(localUrl);
289
- console.log(chalk_1.default.yellow(`\n[Dashboard]: Local server running at ${localUrl}`));
290
- console.log(chalk_1.default.yellow("[Dashboard]: Waiting for UI connection..."));
291
- while (this.wss.clients.size === 0)
292
- await new Promise((r) => setTimeout(r, 250));
293
- console.log(chalk_1.default.green("[Dashboard]: Connected to UI!"));
294
- await this.executeTestRun(targetPath);
279
+ return new Promise((resolve, reject) => {
280
+ this.httpServer.listen(0, async () => {
281
+ try {
282
+ const address = this.httpServer?.address();
283
+ const port = typeof address === "object" && address !== null
284
+ ? address.port
285
+ : null;
286
+ if (!port) {
287
+ console.error(chalk_1.default.red("\n[Error]: Could not bind to an available port."));
288
+ return reject(new Error("No port available"));
289
+ }
290
+ const localUrl = `http://localhost:${port}`;
291
+ console.log(chalk_1.default.yellow(`\n[Dashboard]: Local server running at ${localUrl}`));
292
+ const isStrictHeadless = process.argv.includes("--headless") ||
293
+ !!process.env.CI ||
294
+ !!process.env.GITHUB_ACTIONS ||
295
+ !!process.env.FLOWSTRIDE_API_KEY;
296
+ if (!isStrictHeadless) {
297
+ const { default: open } = await import("open");
298
+ await open(localUrl);
299
+ console.log(chalk_1.default.yellow("[Dashboard]: Waiting for UI connection..."));
300
+ while (this.wss.clients.size === 0)
301
+ await new Promise((r) => setTimeout(r, 250));
302
+ console.log(chalk_1.default.green("[Dashboard]: Connected to UI!"));
303
+ }
304
+ else {
305
+ console.log(chalk_1.default.gray("[Dashboard]: Headless mode active. Bypassing UI auto-launch..."));
306
+ }
307
+ await this.executeTestRun(targetPath);
308
+ resolve(undefined);
309
+ }
310
+ catch (err) {
311
+ reject(err);
312
+ }
313
+ });
295
314
  });
296
315
  }
297
316
  catch (e) {
@@ -131,6 +131,9 @@ class FlowWorker extends events_1.EventEmitter {
131
131
  return context;
132
132
  }
133
133
  resolveTextVariables(text) {
134
+ if (typeof text !== "string") {
135
+ return text;
136
+ }
134
137
  let resolved = text;
135
138
  resolved = resolved.replace(/\{\{env\.([a-zA-Z0-9_]+)\}\}/g, (_, varName) => {
136
139
  const val = process.env[varName];
@@ -259,11 +262,21 @@ class FlowWorker extends events_1.EventEmitter {
259
262
  catch {
260
263
  rawBodyString = typeof body === "string" ? body : null;
261
264
  }
262
- const curlString = this.web.synthesizeCurl(method, cleanUrl, headers || {}, rawBodyString);
265
+ let formattedHeaders = {};
266
+ if (Array.isArray(headers)) {
267
+ headers.forEach((h) => {
268
+ if (h && h.key)
269
+ formattedHeaders[h.key] = h.value;
270
+ });
271
+ }
272
+ else if (headers && typeof headers === "object") {
273
+ formattedHeaders = headers;
274
+ }
275
+ const curlString = this.web.synthesizeCurl(method, cleanUrl, formattedHeaders, rawBodyString);
263
276
  this.emitLog("REST", (state.status || 500) < 400 ? "success" : "error", `${method.toUpperCase()} ${endpointName}`, {
264
277
  method: method.toUpperCase(),
265
278
  url: cleanUrl,
266
- requestHeaders: headers,
279
+ requestHeaders: formattedHeaders,
267
280
  requestBody: parsedReqBody,
268
281
  responseStatus: state.status,
269
282
  responseHeaders: state.headers,
@@ -340,7 +353,14 @@ class FlowWorker extends events_1.EventEmitter {
340
353
  this.isAborted = true;
341
354
  }
342
355
  async runBatch(filesToProcess) {
343
- this.config.headless = true;
356
+ // this.config.headless = true;
357
+ const isCliHeadless = process.argv.includes("--headless");
358
+ if (isCliHeadless) {
359
+ this.config.headless = true;
360
+ }
361
+ else if (this.config.headless === undefined) {
362
+ this.config.headless = false;
363
+ }
344
364
  this.activeAudioPath = undefined;
345
365
  this.isAborted = false;
346
366
  this.isDebugHalted = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowstride",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "An opensource flowfirst test automation framework",
5
5
  "workspaces": [
6
6
  "packages/flowstride_report"