flowstride 1.0.20 → 1.0.21

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 (47) hide show
  1. package/NATIVE_BROWSER.md +341 -0
  2. package/NATIVE_CI.md +3 -3
  3. package/README.md +3 -3
  4. package/dist/adapters/api/undici.d.ts +8 -0
  5. package/dist/adapters/api/undici.js +11 -0
  6. package/dist/adapters/web/playwright.d.ts +22 -2
  7. package/dist/adapters/web/playwright.js +233 -36
  8. package/dist/ci/config.js +14 -1
  9. package/dist/ci/distribution.d.ts +38 -0
  10. package/dist/ci/distribution.js +134 -0
  11. package/dist/ci/init.d.ts +4 -1
  12. package/dist/ci/init.js +13 -2
  13. package/dist/ci/runtime.js +24 -5
  14. package/dist/cli/commands/run.js +3 -0
  15. package/dist/cli/index.js +24 -5
  16. package/dist/manual/security.d.ts +2 -2
  17. package/dist/native/accessibility.d.ts +3 -0
  18. package/dist/native/accessibility.js +68 -0
  19. package/dist/native/assertions.d.ts +2 -0
  20. package/dist/native/assertions.js +140 -0
  21. package/dist/native/compatibility.d.ts +3 -0
  22. package/dist/native/compatibility.js +16 -0
  23. package/dist/native/components.d.ts +12 -0
  24. package/dist/native/components.js +122 -0
  25. package/dist/native/dashboard.d.ts +7 -0
  26. package/dist/native/dashboard.js +32 -0
  27. package/dist/native/events.d.ts +34 -0
  28. package/dist/native/events.js +281 -0
  29. package/dist/native/evidence.d.ts +3 -0
  30. package/dist/native/evidence.js +31 -0
  31. package/dist/native/functions.d.ts +9 -0
  32. package/dist/native/functions.js +103 -0
  33. package/dist/native/runtime.d.ts +14 -0
  34. package/dist/native/runtime.js +154 -0
  35. package/dist/native/validation.d.ts +107 -0
  36. package/dist/native/validation.js +84 -0
  37. package/dist/parser/lexer.js +15 -4
  38. package/dist/parser/parser.js +1 -1
  39. package/dist/runner/flow_orchestrator.d.ts +4 -0
  40. package/dist/runner/flow_orchestrator.js +22 -5
  41. package/dist/runner/flow_worker.d.ts +7 -0
  42. package/dist/runner/flow_worker.js +102 -3
  43. package/dist/types/index.d.ts +10 -0
  44. package/package.json +20 -9
  45. package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
  46. package/src/ui/dist/index.html +1 -1
  47. package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ import type { BrowserContextOptions } from 'playwright';
3
+ import type { FlowstrideConfig } from '../types';
4
+ export declare const name: z.ZodString;
5
+ export declare const positive: z.ZodNumber;
6
+ export declare const headers: z.ZodRecord<z.ZodString, z.ZodString>;
7
+ export declare const contextOptions: z.ZodObject<{
8
+ acceptDownloads: z.ZodOptional<z.ZodBoolean>;
9
+ strictSelectors: z.ZodOptional<z.ZodBoolean>;
10
+ clientCertificates: z.ZodOptional<z.ZodArray<z.ZodObject<{
11
+ origin: z.ZodString;
12
+ certPath: z.ZodOptional<z.ZodString>;
13
+ keyPath: z.ZodOptional<z.ZodString>;
14
+ pfxPath: z.ZodOptional<z.ZodString>;
15
+ passphrase: z.ZodOptional<z.ZodString>;
16
+ }, z.core.$strict>>>;
17
+ storageState: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
18
+ cookies: z.ZodArray<z.ZodObject<{
19
+ name: z.ZodString;
20
+ value: z.ZodString;
21
+ domain: z.ZodString;
22
+ path: z.ZodString;
23
+ expires: z.ZodNumber;
24
+ httpOnly: z.ZodBoolean;
25
+ secure: z.ZodBoolean;
26
+ sameSite: z.ZodEnum<{
27
+ Strict: "Strict";
28
+ Lax: "Lax";
29
+ None: "None";
30
+ }>;
31
+ }, z.core.$strict>>;
32
+ origins: z.ZodArray<z.ZodObject<{
33
+ origin: z.ZodString;
34
+ localStorage: z.ZodArray<z.ZodObject<{
35
+ name: z.ZodString;
36
+ value: z.ZodString;
37
+ }, z.core.$strict>>;
38
+ indexedDB: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>]>>;
41
+ baseURL: z.ZodOptional<z.ZodString>;
42
+ bypassCSP: z.ZodOptional<z.ZodBoolean>;
43
+ colorScheme: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
44
+ light: "light";
45
+ dark: "dark";
46
+ "no-preference": "no-preference";
47
+ }>>>;
48
+ contrast: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
49
+ "no-preference": "no-preference";
50
+ more: "more";
51
+ }>>>;
52
+ deviceScaleFactor: z.ZodOptional<z.ZodNumber>;
53
+ extraHTTPHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
54
+ forcedColors: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
55
+ none: "none";
56
+ active: "active";
57
+ }>>>;
58
+ hasTouch: z.ZodOptional<z.ZodBoolean>;
59
+ httpCredentials: z.ZodOptional<z.ZodObject<{
60
+ username: z.ZodString;
61
+ password: z.ZodString;
62
+ origin: z.ZodOptional<z.ZodString>;
63
+ send: z.ZodOptional<z.ZodEnum<{
64
+ always: "always";
65
+ unauthorized: "unauthorized";
66
+ }>>;
67
+ }, z.core.$strict>>;
68
+ geolocation: z.ZodOptional<z.ZodObject<{
69
+ latitude: z.ZodNumber;
70
+ longitude: z.ZodNumber;
71
+ accuracy: z.ZodOptional<z.ZodNumber>;
72
+ }, z.core.$strict>>;
73
+ ignoreHTTPSErrors: z.ZodOptional<z.ZodBoolean>;
74
+ isMobile: z.ZodOptional<z.ZodBoolean>;
75
+ javaScriptEnabled: z.ZodOptional<z.ZodBoolean>;
76
+ locale: z.ZodOptional<z.ZodString>;
77
+ offline: z.ZodOptional<z.ZodBoolean>;
78
+ permissions: z.ZodOptional<z.ZodArray<z.ZodString>>;
79
+ proxy: z.ZodOptional<z.ZodObject<{
80
+ server: z.ZodString;
81
+ bypass: z.ZodOptional<z.ZodString>;
82
+ username: z.ZodOptional<z.ZodString>;
83
+ password: z.ZodOptional<z.ZodString>;
84
+ }, z.core.$strict>>;
85
+ reducedMotion: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
86
+ reduce: "reduce";
87
+ "no-preference": "no-preference";
88
+ }>>>;
89
+ screen: z.ZodOptional<z.ZodObject<{
90
+ width: z.ZodNumber;
91
+ height: z.ZodNumber;
92
+ }, z.core.$strict>>;
93
+ serviceWorkers: z.ZodOptional<z.ZodEnum<{
94
+ allow: "allow";
95
+ block: "block";
96
+ }>>;
97
+ timezoneId: z.ZodOptional<z.ZodString>;
98
+ userAgent: z.ZodOptional<z.ZodString>;
99
+ viewport: z.ZodOptional<z.ZodNullable<z.ZodObject<{
100
+ width: z.ZodNumber;
101
+ height: z.ZodNumber;
102
+ }, z.core.$strict>>>;
103
+ }, z.core.$strict>;
104
+ export declare function validateContextOptions(value: unknown, browser?: string, root?: string): BrowserContextOptions;
105
+ export declare function evidencePath(config: FlowstrideConfig, file: string): string;
106
+ export declare function readProjectFile(config: FlowstrideConfig, file: string, maxSize?: number): Buffer;
107
+ export declare function poll(read: () => Promise<boolean>, timeout: number, label: string): Promise<void>;
@@ -0,0 +1,84 @@
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.contextOptions = exports.headers = exports.positive = exports.name = void 0;
7
+ exports.validateContextOptions = validateContextOptions;
8
+ exports.evidencePath = evidencePath;
9
+ exports.readProjectFile = readProjectFile;
10
+ exports.poll = poll;
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const zod_1 = require("zod");
14
+ const projectPaths_1 = require("../utils/projectPaths");
15
+ exports.name = zod_1.z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/, 'Use a simple name without path separators.');
16
+ exports.positive = zod_1.z.number().int().min(1).max(2147483647);
17
+ exports.headers = zod_1.z.record(zod_1.z.string(), zod_1.z.string());
18
+ const size = zod_1.z.object({ width: exports.positive, height: exports.positive }).strict();
19
+ // File-bearing options are handled explicitly; evidence paths cannot bypass project containment.
20
+ exports.contextOptions = zod_1.z.object({
21
+ acceptDownloads: zod_1.z.boolean().optional(), strictSelectors: zod_1.z.boolean().optional(),
22
+ clientCertificates: zod_1.z.array(zod_1.z.object({ origin: zod_1.z.string().url(), certPath: zod_1.z.string().optional(), keyPath: zod_1.z.string().optional(), pfxPath: zod_1.z.string().optional(), passphrase: zod_1.z.string().optional() }).strict()).optional(),
23
+ storageState: zod_1.z.union([zod_1.z.string(), zod_1.z.object({ cookies: zod_1.z.array(zod_1.z.object({ name: zod_1.z.string(), value: zod_1.z.string(), domain: zod_1.z.string(), path: zod_1.z.string(), expires: zod_1.z.number(), httpOnly: zod_1.z.boolean(), secure: zod_1.z.boolean(), sameSite: zod_1.z.enum(['Strict', 'Lax', 'None']) }).strict()), origins: zod_1.z.array(zod_1.z.object({ origin: zod_1.z.string().url(), localStorage: zod_1.z.array(zod_1.z.object({ name: zod_1.z.string(), value: zod_1.z.string() }).strict()), indexedDB: zod_1.z.array(zod_1.z.unknown()).optional() }).strict()) }).strict()]).optional(),
24
+ baseURL: zod_1.z.string().url().optional(),
25
+ bypassCSP: zod_1.z.boolean().optional(), colorScheme: zod_1.z.enum(['light', 'dark', 'no-preference']).nullable().optional(),
26
+ contrast: zod_1.z.enum(['no-preference', 'more']).nullable().optional(),
27
+ deviceScaleFactor: zod_1.z.number().positive().optional(), extraHTTPHeaders: exports.headers.optional(),
28
+ forcedColors: zod_1.z.enum(['active', 'none']).nullable().optional(), hasTouch: zod_1.z.boolean().optional(),
29
+ httpCredentials: zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string(), origin: zod_1.z.string().url().optional(), send: zod_1.z.enum(['always', 'unauthorized']).optional() }).strict().optional(),
30
+ geolocation: zod_1.z.object({ latitude: zod_1.z.number().min(-90).max(90), longitude: zod_1.z.number().min(-180).max(180), accuracy: zod_1.z.number().nonnegative().optional() }).strict().optional(),
31
+ ignoreHTTPSErrors: zod_1.z.boolean().optional(), isMobile: zod_1.z.boolean().optional(), javaScriptEnabled: zod_1.z.boolean().optional(),
32
+ locale: zod_1.z.string().min(1).optional(), offline: zod_1.z.boolean().optional(), permissions: zod_1.z.array(zod_1.z.string()).optional(),
33
+ proxy: zod_1.z.object({ server: zod_1.z.string().min(1), bypass: zod_1.z.string().optional(), username: zod_1.z.string().optional(), password: zod_1.z.string().optional() }).strict().optional(),
34
+ reducedMotion: zod_1.z.enum(['reduce', 'no-preference']).nullable().optional(), screen: size.optional(),
35
+ serviceWorkers: zod_1.z.enum(['allow', 'block']).optional(), timezoneId: zod_1.z.string().optional(),
36
+ userAgent: zod_1.z.string().optional(), viewport: size.nullable().optional(),
37
+ }).strict();
38
+ function validateContextOptions(value, browser = 'chromium', root = process.cwd()) {
39
+ const parsed = exports.contextOptions.parse(value ?? {});
40
+ if (typeof parsed.storageState === 'string')
41
+ parsed.storageState = (0, projectPaths_1.projectPath)(root, parsed.storageState);
42
+ for (const certificate of parsed.clientCertificates || [])
43
+ for (const key of ['certPath', 'keyPath', 'pfxPath'])
44
+ if (certificate[key])
45
+ certificate[key] = (0, projectPaths_1.projectPath)(root, certificate[key]);
46
+ if (browser === 'firefox' && parsed.isMobile)
47
+ throw new Error('Firefox does not support mobile emulation; use viewport and touch options instead.');
48
+ return parsed;
49
+ }
50
+ function evidencePath(config, file) {
51
+ const safe = exports.name.parse(file);
52
+ const root = config.projectRoot || process.cwd();
53
+ const dir = (0, projectPaths_1.projectPath)(root, config.artifactsDir || 'flowstride-artifacts');
54
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ return (0, projectPaths_1.projectPath)(root, node_path_1.default.join(dir, (0, projectPaths_1.artifactPrefix)(config.testFile) + safe));
56
+ }
57
+ function readProjectFile(config, file, maxSize = 20 * 1024 * 1024) {
58
+ const absolute = (0, projectPaths_1.projectPath)(config.projectRoot || process.cwd(), file);
59
+ if (!node_fs_1.default.statSync(absolute).isFile() || node_fs_1.default.statSync(absolute).size > maxSize)
60
+ throw new Error('Fixture must be a regular project file within the size limit.');
61
+ return node_fs_1.default.readFileSync(absolute);
62
+ }
63
+ async function poll(read, timeout, label) {
64
+ const deadline = Date.now() + exports.positive.parse(timeout);
65
+ let last;
66
+ for (;;) {
67
+ const remaining = deadline - Date.now();
68
+ if (remaining <= 0)
69
+ throw new Error(`Assertion timed out: ${label}`, { cause: last });
70
+ let timer;
71
+ try {
72
+ const ok = await Promise.race([read(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Read timed out')), remaining); })]);
73
+ if (ok)
74
+ return;
75
+ }
76
+ catch (error) {
77
+ last = error;
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
82
+ await new Promise(resolve => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
83
+ }
84
+ }
@@ -53,11 +53,22 @@ class Lexer {
53
53
  let jsonStr = "";
54
54
  let depth = 0;
55
55
  let startLine = this.line;
56
+ let inString = false, escaped = false;
56
57
  while (this.position < this.input.length) {
57
58
  const c = this.input[this.position];
58
- if (c === "{")
59
+ if (inString) {
60
+ if (escaped)
61
+ escaped = false;
62
+ else if (c === "\\")
63
+ escaped = true;
64
+ else if (c === '"')
65
+ inString = false;
66
+ }
67
+ else if (c === '"')
68
+ inString = true;
69
+ else if (c === "{")
59
70
  depth++;
60
- if (c === "}")
71
+ else if (c === "}")
61
72
  depth--;
62
73
  if (c === "\n")
63
74
  this.line++;
@@ -217,7 +228,7 @@ class Lexer {
217
228
  });
218
229
  continue;
219
230
  }
220
- else if (ident.includes(".") && !lowerIdent.startsWith("flow.")) {
231
+ else if (/^flow\.(browser|assert|network|download|clock|stub|trace|har|a11y|component)\.[a-z]+$/i.test(ident) || (ident.includes(".") && !lowerIdent.startsWith("flow."))) {
221
232
  tokens.push({
222
233
  type: TokenType.ALIAS_COMMAND,
223
234
  value: ident,
@@ -392,7 +403,7 @@ class Lexer {
392
403
  line: this.line,
393
404
  });
394
405
  }
395
- else if (ident.includes(".") && !lowerIdent.startsWith("flow.")) {
406
+ else if (/^flow\.(browser|assert|network|download|clock|stub|trace|har|a11y|component)\.[a-z]+$/i.test(ident) || (ident.includes(".") && !lowerIdent.startsWith("flow."))) {
396
407
  tokens.push({
397
408
  type: TokenType.ALIAS_COMMAND,
398
409
  value: ident,
@@ -242,7 +242,7 @@ class Parser {
242
242
  return {
243
243
  id: `scenario_${this.scenarioCounter}_S${this.stepCounter++}`,
244
244
  blockDescription: this.currentBlockDescription,
245
- action: "plugin",
245
+ action: alias === "flow" ? "native" : "plugin",
246
246
  payload: {
247
247
  pluginAlias: alias,
248
248
  pluginMethod: method,
@@ -4,6 +4,7 @@ export declare class FlowOrchestrator {
4
4
  private worker;
5
5
  private workerCount;
6
6
  private config?;
7
+ static readonly nativeBrowserProtocol = 2;
7
8
  static readonly nativeCIProtocol = 1;
8
9
  readonly nativeEventProtocol = 1;
9
10
  private wss;
@@ -12,6 +13,9 @@ export declare class FlowOrchestrator {
12
13
  private stepStatusRegistry;
13
14
  private pdfGenerator;
14
15
  private disconnectTimer;
16
+ private dashboardBrowser;
17
+ private closing;
18
+ private browserInfoByFile;
15
19
  constructor(worker: FlowWorker, workerCount?: number, config?: FlowstrideConfig | undefined);
16
20
  private forwardToDashboard;
17
21
  private executeTestRun;
@@ -37,8 +37,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FlowOrchestrator = void 0;
40
+ const dashboard_1 = require("../native/dashboard");
41
+ const evidence_1 = require("../native/evidence");
42
+ const distribution_1 = require("../ci/distribution");
40
43
  const localDashboard_1 = require("../security/localDashboard");
41
- const projectPaths_1 = require("../utils/projectPaths");
42
44
  const fs = __importStar(require("fs"));
43
45
  const path = __importStar(require("path"));
44
46
  const http = __importStar(require("http"));
@@ -50,6 +52,7 @@ class FlowOrchestrator {
50
52
  worker;
51
53
  workerCount;
52
54
  config;
55
+ static nativeBrowserProtocol = 2;
53
56
  static nativeCIProtocol = 1;
54
57
  nativeEventProtocol = 1;
55
58
  wss = null;
@@ -58,6 +61,9 @@ class FlowOrchestrator {
58
61
  stepStatusRegistry = new Map();
59
62
  pdfGenerator;
60
63
  disconnectTimer = null;
64
+ dashboardBrowser = new dashboard_1.DashboardBrowser();
65
+ closing = false;
66
+ browserInfoByFile = new Map();
61
67
  constructor(worker, workerCount = 1, config) {
62
68
  this.worker = worker;
63
69
  this.workerCount = workerCount;
@@ -68,6 +74,10 @@ class FlowOrchestrator {
68
74
  });
69
75
  }
70
76
  forwardToDashboard(type, payload) {
77
+ if (type === 'BROWSER_INFO')
78
+ this.browserInfoByFile.set(payload.fileName, payload);
79
+ if (type === 'SUITE_START')
80
+ this.browserInfoByFile.clear();
71
81
  this.config?.onRunEvent?.({ type, payload });
72
82
  if (this.wss) {
73
83
  this.wss.clients.forEach((client) => {
@@ -78,7 +88,7 @@ class FlowOrchestrator {
78
88
  }
79
89
  async executeTestRun(targetPath) {
80
90
  this.forwardToDashboard("SUITE_START", {});
81
- const filesToProcess = (0, projectPaths_1.discoverFlows)(targetPath);
91
+ const filesToProcess = (0, distribution_1.executionPlan)(targetPath, this.config).files;
82
92
  if (this.config)
83
93
  this.config.flowRoot = fs.statSync(targetPath).isDirectory() ? targetPath : path.dirname(targetPath);
84
94
  this.globalTestScenarios = [];
@@ -115,6 +125,8 @@ class FlowOrchestrator {
115
125
  return;
116
126
  }
117
127
  res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
128
+ if ((0, evidence_1.serveDOMSnapshot)(req, res, this.config))
129
+ return;
118
130
  if (req.url && req.url.startsWith("/videos/")) {
119
131
  let urlObj;
120
132
  try {
@@ -229,6 +241,8 @@ class FlowOrchestrator {
229
241
  });
230
242
  this.wss = new ws_1.WebSocketServer({ server: this.httpServer, verifyClient: (info) => Boolean(info.origin) && (0, localDashboard_1.isLocalDashboardRequest)(info.req.headers.host, info.origin) });
231
243
  this.wss.on("connection", (ws) => {
244
+ for (const payload of this.browserInfoByFile.values())
245
+ ws.send(JSON.stringify({ type: 'BROWSER_INFO', payload }));
232
246
  if (this.disconnectTimer) {
233
247
  clearTimeout(this.disconnectTimer);
234
248
  this.disconnectTimer = null;
@@ -267,7 +281,7 @@ class FlowOrchestrator {
267
281
  }
268
282
  });
269
283
  ws.on("close", () => {
270
- if (this.wss && this.wss.clients.size === 0) {
284
+ if (!this.closing && this.wss && this.wss.clients.size === 0) {
271
285
  this.disconnectTimer = setTimeout(async () => {
272
286
  console.log(chalk_1.default.yellow("\n[Flowstride]: Dashboard disconnected. Shutting down framework..."));
273
287
  this.worker.abort();
@@ -300,8 +314,8 @@ class FlowOrchestrator {
300
314
  !!process.env.GITHUB_ACTIONS ||
301
315
  !!process.env.FLOWSTRIDE_API_KEY;
302
316
  if (!isStrictHeadless) {
303
- const { default: open } = await import("open");
304
- await open(localUrl);
317
+ await this.dashboardBrowser.open(localUrl, this.config?.browser);
318
+ console.log(`[Dashboard]: ${this.config?.browser === 'firefox' || this.config?.browser === 'webkit' ? this.config.browser : 'system default'} dashboard window; test browser reported after launch${this.config?.headless ? ' (headless tests)' : ''}.`);
305
319
  console.log(chalk_1.default.yellow("[Dashboard]: Waiting for UI connection..."));
306
320
  while (this.wss.clients.size === 0)
307
321
  await new Promise((r) => setTimeout(r, 250));
@@ -314,6 +328,7 @@ class FlowOrchestrator {
314
328
  resolve(result);
315
329
  }
316
330
  catch (err) {
331
+ await this.dashboardBrowser.close();
317
332
  reject(err);
318
333
  }
319
334
  });
@@ -327,6 +342,8 @@ class FlowOrchestrator {
327
342
  this.worker.abort();
328
343
  }
329
344
  async close() {
345
+ this.closing = true;
346
+ await this.dashboardBrowser.close();
330
347
  if (this.disconnectTimer)
331
348
  clearTimeout(this.disconnectTimer);
332
349
  this.wss?.clients.forEach(client => client.terminate());
@@ -19,10 +19,16 @@ export declare class FlowWorker extends EventEmitter {
19
19
  private store;
20
20
  private sessions;
21
21
  private pluginManager?;
22
+ static readonly nativeBrowserProtocol = 2;
22
23
  private isSilent;
23
24
  private isAborted;
24
25
  private bailTriggered;
25
26
  private isDebugHalted;
27
+ private native;
28
+ private attempt;
29
+ private maxAttempts;
30
+ private fallbackStream?;
31
+ private interceptedContexts;
26
32
  private currentStepContext;
27
33
  private currentFileName;
28
34
  private activeAudioPath;
@@ -46,6 +52,7 @@ export declare class FlowWorker extends EventEmitter {
46
52
  handleReplayRequest(payload: any): Promise<void>;
47
53
  abort(): void;
48
54
  runBatch(filesToProcess: string[]): Promise<void>;
55
+ private runScenarioWithRetries;
49
56
  private runScenario;
50
57
  private waitForPipeline;
51
58
  private executeStep;
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FlowWorker = void 0;
40
+ const runtime_1 = require("../native/runtime");
40
41
  const deadline_1 = require("../ci/deadline");
41
42
  const replayRequest_1 = require("../utils/replayRequest");
42
43
  const projectPaths_1 = require("../utils/projectPaths");
@@ -57,10 +58,16 @@ class FlowWorker extends events_1.EventEmitter {
57
58
  store;
58
59
  sessions;
59
60
  pluginManager;
61
+ static nativeBrowserProtocol = 2;
60
62
  isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
61
63
  isAborted = false;
62
64
  bailTriggered = false;
63
65
  isDebugHalted = false;
66
+ native;
67
+ attempt = 1;
68
+ maxAttempts = 1;
69
+ fallbackStream;
70
+ interceptedContexts = new WeakSet();
64
71
  currentStepContext = "Initializing...";
65
72
  currentFileName = "System";
66
73
  activeAudioPath = undefined;
@@ -79,8 +86,12 @@ class FlowWorker extends events_1.EventEmitter {
79
86
  this.store = store;
80
87
  this.sessions = sessions;
81
88
  this.pluginManager = pluginManager;
89
+ this.native = new runtime_1.NativeRuntime(web, config);
90
+ this.web.onPageChanged = async () => { await this.startStreaming(); await this.setupNetworkInterception(); };
82
91
  }
83
92
  broadcast(type, payload) {
93
+ if (["STEP_ADD", "STEP_UPDATE", "LOG_EVENT"].includes(type))
94
+ payload = { ...payload, attempt: this.attempt, profile: this.web.activeProfile || "default" };
84
95
  const secrets = [this.config.machineToken || '', ...(this.dataGen?.sensitiveValues?.() || []), ...Object.entries(process.env).filter(([key]) => /password|token|secret|api.?key/i.test(key)).map(([, value]) => String(value)), ...Object.entries(this.localVariables).filter(([key]) => /password|passcode|token|secret|otp|api.?key/i.test(key)).map(([, value]) => String(value))];
85
96
  this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
86
97
  }
@@ -394,7 +405,13 @@ class FlowWorker extends events_1.EventEmitter {
394
405
  this.localBrowserSessions = {};
395
406
  this.api.resetState();
396
407
  this.dataGen = new data_generators_1.DataGenerators();
408
+ await this.native.dispose();
397
409
  await this.web.initialise(this.activeAudioPath);
410
+ if (typeof this.web.getBrowserInfo === 'function') {
411
+ const browser = this.web.getBrowserInfo();
412
+ console.log(`[Browser]: ${browser.engine} ${browser.version}; ${browser.headless ? 'headless' : 'headed'}; ${fileName}`);
413
+ this.broadcast('BROWSER_INFO', { ...browser, fileName });
414
+ }
398
415
  await this.startStreaming();
399
416
  await this.setupNetworkInterception();
400
417
  const content = fs.readFileSync(filePath, "utf8");
@@ -453,7 +470,7 @@ class FlowWorker extends events_1.EventEmitter {
453
470
  scenarioName: s.name,
454
471
  fileName: this.currentFileName,
455
472
  });
456
- await this.runScenario(s, f.declarations);
473
+ await this.runScenarioWithRetries(s, f.declarations);
457
474
  if (this.isDebugHalted || this.bailTriggered)
458
475
  break;
459
476
  await this.web
@@ -482,6 +499,7 @@ class FlowWorker extends events_1.EventEmitter {
482
499
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
483
500
  for (const message of this.web.pageErrors || [])
484
501
  this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
502
+ await this.native.dispose();
485
503
  await this.web.shutdown(targetVideoPath);
486
504
  if (!this.isSilent)
487
505
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -505,6 +523,7 @@ class FlowWorker extends events_1.EventEmitter {
505
523
  const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
506
524
  for (const message of this.web.pageErrors || [])
507
525
  this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
526
+ await this.native.dispose();
508
527
  await this.web.shutdown(targetVideoPath);
509
528
  if (!this.isSilent)
510
529
  console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
@@ -531,6 +550,7 @@ class FlowWorker extends events_1.EventEmitter {
531
550
  try {
532
551
  const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
533
552
  fs.mkdirSync(directory, { recursive: true });
553
+ await this.native.dispose();
534
554
  await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
535
555
  }
536
556
  catch (error) {
@@ -540,9 +560,52 @@ class FlowWorker extends events_1.EventEmitter {
540
560
  process.removeListener("SIGINT", sigintHandler);
541
561
  }
542
562
  }
563
+ async runScenarioWithRetries(scenario, declarations) {
564
+ this.maxAttempts = (this.config.retries || 0) + 1;
565
+ const baseline = this.maxAttempts > 1 ? await this.web.checkpointProfiles() : undefined;
566
+ const variables = this.maxAttempts > 1 ? structuredClone(this.localVariables) : {}, sessions = this.maxAttempts > 1 ? structuredClone(this.localBrowserSessions) : {};
567
+ const globals = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalVariables) : {}, globalSessions = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalBrowserSessions) : {};
568
+ const storeState = this.maxAttempts > 1 ? structuredClone(this.store.getAll?.()) : undefined;
569
+ const apiState = this.maxAttempts > 1 ? this.api.checkpointState?.() : undefined;
570
+ for (this.attempt = 1; this.attempt <= this.maxAttempts; this.attempt++) {
571
+ if (this.isAborted || this.isDebugHalted)
572
+ break;
573
+ if (this.attempt > 1) {
574
+ await this.native.dispose();
575
+ if (apiState)
576
+ this.api.restoreState(apiState);
577
+ else
578
+ this.api.resetState();
579
+ this.localVariables = structuredClone(variables);
580
+ this.localBrowserSessions = structuredClone(sessions);
581
+ FlowWorker.globalVariables = structuredClone(globals);
582
+ FlowWorker.globalBrowserSessions = structuredClone(globalSessions);
583
+ if (storeState) {
584
+ this.store.clear();
585
+ for (const [key, value] of Object.entries(structuredClone(storeState)))
586
+ this.store.set(key, value);
587
+ }
588
+ await this.web.restoreProfiles(baseline);
589
+ }
590
+ const started = Date.now();
591
+ this.broadcast('SCENARIO_ATTEMPT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt });
592
+ const errorIndex = this.web.pageErrors?.length || 0;
593
+ const completed = await this.runScenario(structuredClone(scenario), declarations);
594
+ const browserErrors = this.web.pageErrors?.slice(errorIndex) || [];
595
+ const passed = completed && (this.config.browserErrors === 'record' || !browserErrors.length);
596
+ this.broadcast('SCENARIO_RESULT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt, status: this.isAborted ? 'interrupted' : passed ? 'passed' : 'failed', flaky: passed && this.attempt > 1, browserErrors, duration: Date.now() - started });
597
+ if (passed || this.isAborted || this.isDebugHalted)
598
+ break;
599
+ if (this.attempt < this.maxAttempts)
600
+ this.web.pageErrors?.splice(errorIndex);
601
+ }
602
+ this.attempt = 1;
603
+ this.maxAttempts = 1;
604
+ }
543
605
  async runScenario(scenario, globalDeclarations = []) {
544
606
  let hasScenarioFailed = false;
545
- for (const step of scenario.steps) {
607
+ for (const originalStep of scenario.steps) {
608
+ const step = structuredClone(originalStep);
546
609
  if (this.isAborted)
547
610
  break;
548
611
  const blockName = step.blockDescription.split(" ")[0];
@@ -597,11 +660,13 @@ class FlowWorker extends events_1.EventEmitter {
597
660
  try {
598
661
  await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
599
662
  const stepContext = await this.captureStepContext(step.payload);
663
+ const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
600
664
  spinner.succeed();
601
665
  this.broadcast("STEP_UPDATE", {
602
666
  id: step.id,
603
667
  status: "passed",
604
668
  screenshot: stepContext.screenshot,
669
+ domSnapshot,
605
670
  fileName: this.currentFileName,
606
671
  });
607
672
  if (step.isStop) {
@@ -616,6 +681,7 @@ class FlowWorker extends events_1.EventEmitter {
616
681
  if (this.isAborted)
617
682
  break;
618
683
  const stepContext = await this.captureStepContext(step.payload);
684
+ const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
619
685
  let strippedDom = "";
620
686
  try {
621
687
  const page = this.web.getPage();
@@ -637,6 +703,7 @@ class FlowWorker extends events_1.EventEmitter {
637
703
  id: step.id,
638
704
  status: "skipped",
639
705
  screenshot: stepContext.screenshot,
706
+ domSnapshot,
640
707
  fileName: this.currentFileName,
641
708
  });
642
709
  continue;
@@ -684,15 +751,17 @@ class FlowWorker extends events_1.EventEmitter {
684
751
  id: step.id,
685
752
  status: "failed",
686
753
  screenshot: stepContext.screenshot,
754
+ domSnapshot,
687
755
  fileName: this.currentFileName,
688
756
  });
689
757
  hasScenarioFailed = true;
690
- if (this.config.bail && !this.bailTriggered) {
758
+ if (this.config.bail && this.attempt >= this.maxAttempts && !this.bailTriggered) {
691
759
  this.bailTriggered = true;
692
760
  this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
693
761
  }
694
762
  }
695
763
  }
764
+ return !hasScenarioFailed;
696
765
  }
697
766
  async waitForPipeline() {
698
767
  if (!this.activeAudioPath || !fs.existsSync(this.activeAudioPath)) {
@@ -739,6 +808,12 @@ class FlowWorker extends events_1.EventEmitter {
739
808
  ].join(", ");
740
809
  }
741
810
  switch (action) {
811
+ case 'native': {
812
+ const result = await (0, deadline_1.withDeadline)(this.native.execute(payload.pluginMethod, payload.data), this.config.timeout);
813
+ if (payload.saveAs)
814
+ this.localVariables[payload.saveAs] = result;
815
+ break;
816
+ }
742
817
  case "extract":
743
818
  if (["resBody", "resHeader", "cookie"].includes(payload.target)) {
744
819
  this.processExtractions([
@@ -1181,6 +1256,9 @@ class FlowWorker extends events_1.EventEmitter {
1181
1256
  }
1182
1257
  async setupNetworkInterception() {
1183
1258
  const context = this.web.getPage().context();
1259
+ if (this.interceptedContexts.has(context))
1260
+ return;
1261
+ this.interceptedContexts.add(context);
1184
1262
  context.on("response", async (response) => {
1185
1263
  const lockedFileName = this.currentFileName;
1186
1264
  const request = response.request();
@@ -1239,8 +1317,27 @@ class FlowWorker extends events_1.EventEmitter {
1239
1317
  });
1240
1318
  }
1241
1319
  async startStreaming() {
1320
+ clearInterval(this.fallbackStream);
1242
1321
  if (this.config.ci)
1243
1322
  return;
1323
+ if (this.config.browser && this.config.browser !== 'chromium') {
1324
+ const page = this.web.getPage();
1325
+ let busy = false;
1326
+ this.fallbackStream = setInterval(async () => {
1327
+ if (busy || page.isClosed())
1328
+ return;
1329
+ busy = true;
1330
+ try {
1331
+ this.broadcast('BROWSER_FRAME', { base64: (await page.screenshot({ type: 'jpeg', quality: 60 })).toString('base64'), fileName: this.currentFileName });
1332
+ }
1333
+ catch { }
1334
+ finally {
1335
+ busy = false;
1336
+ }
1337
+ }, 250);
1338
+ this.fallbackStream.unref();
1339
+ return;
1340
+ }
1244
1341
  const page = this.web.getPage();
1245
1342
  if (this.currentCdpSession) {
1246
1343
  await this.currentCdpSession.detach().catch(() => { });
@@ -1261,6 +1358,8 @@ class FlowWorker extends events_1.EventEmitter {
1261
1358
  });
1262
1359
  }
1263
1360
  async shutdown() {
1361
+ clearInterval(this.fallbackStream);
1362
+ await this.native.dispose();
1264
1363
  try {
1265
1364
  await this.web.shutdown();
1266
1365
  }
@@ -1,4 +1,14 @@
1
1
  export interface FlowstrideConfig {
2
+ browser?: 'chromium' | 'firefox' | 'webkit';
3
+ browserOptions?: import('playwright').BrowserContextOptions;
4
+ retries?: number;
5
+ failOnFlaky?: boolean;
6
+ trace?: boolean;
7
+ har?: boolean;
8
+ domSnapshots?: boolean;
9
+ shard?: string;
10
+ shardHistory?: string;
11
+ updateBaselines?: boolean;
2
12
  ci?: boolean;
3
13
  flowRoot?: string;
4
14
  projectRoot?: string;