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
@@ -37,34 +37,192 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.WebAdapter = void 0;
40
- const projectPaths_1 = require("../../utils/projectPaths");
40
+ const validation_1 = require("../../native/validation");
41
41
  const playwright_1 = require("playwright");
42
+ const projectPaths_1 = require("../../utils/projectPaths");
43
+ const playwright_2 = require("playwright");
42
44
  const fs = __importStar(require("fs"));
43
45
  const path = __importStar(require("path"));
44
46
  const playwright_extra_1 = require("playwright-extra");
45
47
  const puppeteer_extra_plugin_stealth_1 = __importDefault(require("puppeteer-extra-plugin-stealth"));
46
- playwright_extra_1.chromium.use((0, puppeteer_extra_plugin_stealth_1.default)());
48
+ const chromium = (0, playwright_extra_1.addExtra)(playwright_1.chromium);
49
+ chromium.use((0, puppeteer_extra_plugin_stealth_1.default)());
47
50
  class WebAdapter {
48
51
  config;
52
+ activeProfile = 'default';
53
+ profiles = new Map();
54
+ contextOptions = {};
55
+ onPageChanged;
56
+ profileNames() { return [...this.profiles.keys()]; }
57
+ getContext() { if (!this.context)
58
+ throw new Error('Browser not initialised'); return this.context; }
59
+ async createProfile(profileName, options = {}) {
60
+ const key = validation_1.name.parse(profileName);
61
+ if (this.profiles.has(key))
62
+ throw new Error(`Browser profile already exists: ${key}`);
63
+ if (!this.browser)
64
+ throw new Error('Browser not initialised');
65
+ const { storageState: _defaultAuthentication, ...inherited } = this.contextOptions;
66
+ const settings = { ...inherited, ...(0, validation_1.validateContextOptions)(options, this.config.browser, this.config.projectRoot) };
67
+ if (this.config.har)
68
+ settings.recordHar = { path: (0, validation_1.evidencePath)(this.config, `${key}-${Date.now()}.har`), content: 'embed', mode: 'full' };
69
+ const context = await this.browser.newContext(settings);
70
+ context.setDefaultTimeout(this.config.timeout);
71
+ context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
72
+ context.on('dialog', async (dialog) => { if (this.dialogHandler)
73
+ await this.dialogHandler(dialog);
74
+ else if (this.config.defaultDialogBehavior === 'accept')
75
+ await dialog.accept().catch(() => { });
76
+ else
77
+ await dialog.dismiss().catch(() => { }); });
78
+ try {
79
+ const page = await context.newPage();
80
+ this.profiles.set(key, { context, page, options: settings, tracing: false });
81
+ if (this.config.trace)
82
+ await this.beginTrace(key);
83
+ await this.useProfile(key);
84
+ }
85
+ catch (error) {
86
+ await context.close();
87
+ this.profiles.delete(key);
88
+ throw error;
89
+ }
90
+ }
91
+ async useProfile(profileName) {
92
+ const profile = this.profiles.get(profileName);
93
+ if (!profile || profile.page.isClosed())
94
+ throw new Error(`Browser profile is not available: ${profileName}`);
95
+ const previous = this.profiles.get(this.activeProfile);
96
+ if (previous && this.page && !this.page.isClosed() && this.context === previous.context)
97
+ previous.page = this.page;
98
+ // Initializers belong to their own page, even while another profile is active.
99
+ this.context = profile.context;
100
+ this.page = profile.page;
101
+ this.activeProfile = profileName;
102
+ await this.page.bringToFront();
103
+ await this.onPageChanged?.();
104
+ }
105
+ async closeProfile(profileName) {
106
+ if (profileName === 'default')
107
+ throw new Error('The default profile remains available until file completion.');
108
+ const profile = this.profiles.get(profileName);
109
+ if (!profile)
110
+ throw new Error(`Unknown profile: ${profileName}`);
111
+ await this.finishProfile(profileName);
112
+ this.profiles.delete(profileName);
113
+ if (this.activeProfile === profileName)
114
+ await this.useProfile('default');
115
+ }
116
+ async beginTrace(profileName = this.activeProfile, file) {
117
+ const profile = this.profiles.get(profileName);
118
+ if (!profile || profile.tracing)
119
+ throw new Error('Profile missing or already tracing.');
120
+ await profile.context.tracing.start({ screenshots: true, snapshots: true, sources: false });
121
+ profile.tracing = true;
122
+ profile.tracePath = (0, validation_1.evidencePath)(this.config, file || `${profileName}-${Date.now()}-trace.zip`);
123
+ }
124
+ async endTrace(profileName = this.activeProfile) {
125
+ const profile = this.profiles.get(profileName);
126
+ if (!profile?.tracing || !profile.tracePath)
127
+ throw new Error('Trace is not active.');
128
+ await profile.context.tracing.stop({ path: profile.tracePath });
129
+ profile.tracing = false;
130
+ return profile.tracePath;
131
+ }
132
+ async finishProfile(key, videoTargetPath) {
133
+ const profile = this.profiles.get(key);
134
+ if (!profile)
135
+ return;
136
+ await this.clearSessionStorageRestore(profile.page);
137
+ if (profile.tracing)
138
+ await this.endTrace(key);
139
+ const video = profile.page.video();
140
+ await profile.context.close();
141
+ if (video) {
142
+ const target = videoTargetPath || (key !== 'default' ? (0, validation_1.evidencePath)(this.config, `${key}-${Date.now()}.webm`) : undefined);
143
+ if (target)
144
+ await video.saveAs(target);
145
+ }
146
+ }
147
+ async checkpointProfiles() {
148
+ const result = { active: this.activeProfile, dialogHandler: this.dialogHandler, profiles: [] };
149
+ const current = this.profiles.get(this.activeProfile);
150
+ if (current && this.page)
151
+ current.page = this.page;
152
+ for (const [key, profile] of this.profiles) {
153
+ const pages = [];
154
+ for (const page of profile.context.pages())
155
+ pages.push({ url: page.url(), active: page === profile.page,
156
+ sessionStorage: await page.evaluate(() => ({ ...window.sessionStorage })).catch(() => ({})) });
157
+ result.profiles.push({ key, options: profile.options, storageState: await profile.context.storageState({ indexedDB: true }), pages });
158
+ }
159
+ return result;
160
+ }
161
+ async restoreProfiles(snapshot) {
162
+ await this.clearSessionStorageRestore();
163
+ for (const [key] of this.profiles)
164
+ await this.finishProfile(key, (0, validation_1.evidencePath)(this.config, `${key}-${Date.now()}-attempt.webm`));
165
+ this.profiles.clear();
166
+ this.dialogHandler = snapshot.dialogHandler;
167
+ for (const saved of snapshot.profiles) {
168
+ const context = await this.browser.newContext({ ...saved.options, storageState: saved.storageState,
169
+ ...(this.config.har ? { recordHar: { path: (0, validation_1.evidencePath)(this.config, `${saved.key}-${Date.now()}-retry.har`), content: 'embed' } } : {}) });
170
+ context.setDefaultTimeout(this.config.timeout);
171
+ context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
172
+ context.on('dialog', async (dialog) => { if (this.dialogHandler)
173
+ await this.dialogHandler(dialog);
174
+ else if (this.config.defaultDialogBehavior === 'accept')
175
+ await dialog.accept().catch(() => { });
176
+ else
177
+ await dialog.dismiss().catch(() => { }); });
178
+ let selected;
179
+ for (const tab of saved.pages) {
180
+ const page = await context.newPage();
181
+ if (tab.active || !selected)
182
+ selected = page;
183
+ if (/^https?:/.test(tab.url)) {
184
+ const origin = new URL(tab.url).origin;
185
+ const seed = await page.addInitScript(({ origin, values }) => { if (window === window.top && location.origin === origin)
186
+ for (const [key, value] of Object.entries(values))
187
+ sessionStorage.setItem(key, String(value)); }, { origin, values: tab.sessionStorage });
188
+ try {
189
+ await page.goto(tab.url, { waitUntil: 'domcontentloaded' });
190
+ }
191
+ finally {
192
+ await seed.dispose();
193
+ }
194
+ }
195
+ }
196
+ selected ||= await context.newPage();
197
+ this.profiles.set(saved.key, { context, page: selected, options: saved.options, tracing: false });
198
+ if (this.config.trace)
199
+ await this.beginTrace(saved.key);
200
+ }
201
+ this.context = null;
202
+ this.page = null;
203
+ await this.useProfile(snapshot.active);
204
+ }
49
205
  pageErrors = [];
50
206
  browser = null;
51
207
  context = null;
52
208
  page = null;
53
209
  discoveredPages = [];
54
210
  dialogHandler = null;
55
- pendingSessionStorageRestore = null;
56
- async clearSessionStorageRestore() {
57
- const pending = this.pendingSessionStorageRestore;
58
- this.pendingSessionStorageRestore = null;
59
- if (!pending)
60
- return;
61
- pending.page.off('framenavigated', pending.onNavigation);
62
- try {
63
- await pending.script.dispose();
64
- }
65
- catch (error) {
66
- if (!pending.page.isClosed())
67
- throw error;
211
+ sessionStorageRestores = new Map();
212
+ get pendingSessionStorageRestore() { return this.sessionStorageRestores.values().next().value || null; }
213
+ async clearSessionStorageRestore(target) {
214
+ for (const [page, pending] of this.sessionStorageRestores) {
215
+ if (target && page !== target)
216
+ continue;
217
+ this.sessionStorageRestores.delete(page);
218
+ pending.page.off('framenavigated', pending.onNavigation);
219
+ try {
220
+ await pending.script.dispose();
221
+ }
222
+ catch (error) {
223
+ if (!pending.page.isClosed())
224
+ throw error;
225
+ }
68
226
  }
69
227
  }
70
228
  constructor(config) {
@@ -75,6 +233,11 @@ class WebAdapter {
75
233
  throw new Error("Browser not initialised");
76
234
  return this.page;
77
235
  }
236
+ getBrowserInfo() {
237
+ if (!this.browser)
238
+ throw new Error('Browser not initialised');
239
+ return { engine: this.browser.browserType().name(), version: this.browser.version(), headless: !!this.config.headless };
240
+ }
78
241
  async initialise(audioInjectionPath) {
79
242
  await this.clearSessionStorageRestore();
80
243
  const launchArgs = this.config.headless
@@ -92,23 +255,48 @@ class WebAdapter {
92
255
  if (audioInjectionPath) {
93
256
  launchArgs.push(`--use-file-for-fake-audio-capture=${audioInjectionPath}`);
94
257
  }
95
- this.browser = await playwright_extra_1.chromium.launch({
258
+ let engine = this.config.browser === 'firefox' ? playwright_1.firefox : this.config.browser === 'webkit' ? playwright_1.webkit : chromium;
259
+ if ((!this.config.browser || this.config.browser === 'chromium') && this.config.browserOptions?.userAgent) {
260
+ const configuredChromium = (0, playwright_extra_1.addExtra)(playwright_1.chromium);
261
+ const stealth = (0, puppeteer_extra_plugin_stealth_1.default)();
262
+ stealth.enabledEvasions.delete('user-agent-override');
263
+ configuredChromium.use(stealth);
264
+ engine = configuredChromium;
265
+ }
266
+ if (audioInjectionPath && this.config.browser && this.config.browser !== 'chromium')
267
+ throw new Error('Audio file injection requires Chromium.');
268
+ this.profiles.clear();
269
+ this.activeProfile = 'default';
270
+ this.discoveredPages = [];
271
+ this.dialogHandler = null;
272
+ this.browser = await engine.launch({
96
273
  headless: this.config.headless,
97
274
  // Native CI finalizes videos before closing the browser on cancellation.
98
275
  ...(this.config.ci ? { handleSIGINT: false, handleSIGTERM: false } : {}),
99
- args: launchArgs,
276
+ args: !this.config.browser || this.config.browser === 'chromium' ? launchArgs : [],
100
277
  });
278
+ if (this.browser.browserType().name() !== (this.config.browser || 'chromium')) {
279
+ await this.browser.close();
280
+ this.browser = null;
281
+ throw new Error('The launched browser does not match the requested browser.');
282
+ }
101
283
  const videoDir = (0, projectPaths_1.projectPath)(process.cwd(), path.join('.flowstride', 'videos'));
102
284
  if (!fs.existsSync(videoDir))
103
285
  fs.mkdirSync(videoDir, { recursive: true });
104
286
  const profile = this.config.device === 'phone' || this.config.device === 'landscape'
105
- ? { ...playwright_1.devices['Pixel 7'], viewport: this.config.device === 'landscape' ? { width: 844, height: 390 } : { width: 390, height: 844 } }
287
+ ? { ...playwright_2.devices['Pixel 7'], viewport: this.config.device === 'landscape' ? { width: 844, height: 390 } : { width: 390, height: 844 } }
106
288
  : { viewport: { width: 1280, height: 800 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' };
289
+ if (this.config.browser && this.config.browser !== 'chromium')
290
+ delete profile.userAgent;
107
291
  const viewport = this.config.viewport || profile.viewport;
108
- this.context = await this.browser.newContext({
292
+ this.contextOptions = {
109
293
  ...profile, viewport,
294
+ ...(this.config.browser === 'firefox' ? { isMobile: false } : {}),
295
+ ...(0, validation_1.validateContextOptions)(this.config.browserOptions, this.config.browser, this.config.projectRoot),
110
296
  ...(this.config.recordVideo === false ? {} : { recordVideo: { dir: videoDir, size: viewport } }),
111
- });
297
+ };
298
+ this.context = await this.browser.newContext({ ...this.contextOptions,
299
+ ...(this.config.har ? { recordHar: { path: (0, validation_1.evidencePath)(this.config, `default-${Date.now()}.har`), content: 'embed', mode: 'full' } } : {}) });
112
300
  this.pageErrors = [];
113
301
  this.context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
114
302
  this.context.on("dialog", async (dialog) => {
@@ -130,6 +318,9 @@ class WebAdapter {
130
318
  });
131
319
  this.page = await this.context.newPage();
132
320
  this.page.setDefaultTimeout(this.config.timeout);
321
+ this.profiles.set('default', { context: this.context, page: this.page, options: this.contextOptions, tracing: false });
322
+ if (this.config.trace)
323
+ await this.beginTrace();
133
324
  }
134
325
  async acceptDialog() {
135
326
  this.dialogHandler = async (dialog) => {
@@ -202,7 +393,7 @@ class WebAdapter {
202
393
  async injectStorage(cookies, localStorage, fallbackDomain, sessionStorage, storageOrigin) {
203
394
  if (!this.context || !this.page)
204
395
  return;
205
- await this.clearSessionStorageRestore();
396
+ await this.clearSessionStorageRestore(this.page);
206
397
  const page = this.page;
207
398
  const originOf = (value) => {
208
399
  try {
@@ -283,11 +474,11 @@ class WebAdapter {
283
474
  const onNavigation = (frame) => {
284
475
  if (frame !== page.mainFrame() || originOf(frame.url()) !== origin)
285
476
  return;
286
- void this.clearSessionStorageRestore().catch(() => {
477
+ void this.clearSessionStorageRestore(page).catch(() => {
287
478
  console.warn('[Session]: Could not remove the completed session storage initializer.');
288
479
  });
289
480
  };
290
- this.pendingSessionStorageRestore = { page, script, onNavigation };
481
+ this.sessionStorageRestores.set(page, { page, script, onNavigation });
291
482
  page.on('framenavigated', onNavigation);
292
483
  }
293
484
  }
@@ -1261,24 +1452,30 @@ class WebAdapter {
1261
1452
  }, { id, text, status, error });
1262
1453
  }
1263
1454
  async shutdown(videoTargetPath) {
1455
+ const failures = [];
1264
1456
  try {
1265
1457
  await this.clearSessionStorageRestore();
1266
- const video = this.page?.video();
1267
- if (this.context) {
1268
- await this.context.close();
1269
- }
1270
- if (video && videoTargetPath) {
1271
- await video.saveAs(videoTargetPath);
1458
+ const active = this.profiles.get(this.activeProfile);
1459
+ if (active && this.page)
1460
+ active.page = this.page;
1461
+ for (const [key] of this.profiles) {
1462
+ try {
1463
+ await this.finishProfile(key, key === 'default' ? videoTargetPath : undefined);
1464
+ }
1465
+ catch (error) {
1466
+ failures.push(error);
1467
+ }
1272
1468
  }
1273
1469
  }
1274
- catch (err) {
1275
- console.warn("[WebAdapter]: Warning during browser shutdown or video transfer.");
1276
- }
1277
1470
  finally {
1278
- if (this.browser) {
1279
- await this.browser.close().catch(() => { });
1280
- }
1281
- }
1471
+ await this.browser?.close().catch(error => failures.push(error));
1472
+ this.profiles.clear();
1473
+ this.context = null;
1474
+ this.page = null;
1475
+ this.browser = null;
1476
+ }
1477
+ if (failures.length)
1478
+ throw new AggregateError(failures, 'Browser evidence finalization failed.');
1282
1479
  }
1283
1480
  }
1284
1481
  exports.WebAdapter = WebAdapter;
package/dist/ci/config.js CHANGED
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isCIEnvironment = isCIEnvironment;
7
7
  exports.loadNativeConfig = loadNativeConfig;
8
+ const distribution_1 = require("./distribution");
9
+ const validation_1 = require("../native/validation");
8
10
  const projectPaths_1 = require("../utils/projectPaths");
9
11
  const node_fs_1 = __importDefault(require("node:fs"));
10
12
  const node_path_1 = __importDefault(require("node:path"));
@@ -22,13 +24,24 @@ function loadNativeConfig(root, options) {
22
24
  }
23
25
  else if (options.config)
24
26
  throw new Error(`Configuration not found: ${file}`);
25
- const config = { ...input, projectRoot: node_path_1.default.resolve(root), timeout: input.timeout ?? 30000, headless: input.headless ?? true,
27
+ const config = { ...input, browser: options.browser ?? input.browser ?? "chromium", retries: input.retries ?? 0, shard: options.shard ?? input.shard, shardHistory: options.shardHistory ?? input.shardHistory, updateBaselines: !!options.updateBaselines, projectRoot: node_path_1.default.resolve(root), timeout: input.timeout ?? 30000, headless: input.headless ?? true,
26
28
  ci: !!options.ci || isCIEnvironment(), bail: options.bail || input.bail || false, device: options.device ?? input.device ?? 'desktop',
27
29
  artifactsDir: input.artifactsDir ?? 'flowstride-artifacts', testDir: input.testDir ?? 'flows',
28
30
  defaultDialogBehavior: input.defaultDialogBehavior ?? 'dismiss',
29
31
  recordVideo: input.recordVideo ?? true, browserErrors: input.browserErrors ?? 'fail',
30
32
  isCloudSyncActive: options.isCloudSyncActive ?? false, machineToken: options.machineToken ?? null,
31
33
  workspaceId: options.workspaceId ?? null, manifest: options.manifest ?? null };
34
+ if (!['chromium', 'firefox', 'webkit'].includes(config.browser))
35
+ throw new Error('browser must be chromium, firefox, or webkit.');
36
+ config.browserOptions = (0, validation_1.validateContextOptions)(input.browserOptions, config.browser, root);
37
+ if (!Number.isSafeInteger(config.retries) || config.retries < 0 || config.retries > 10)
38
+ throw new Error('retries must be an integer from 0 to 10.');
39
+ for (const key of ['trace', 'har', 'domSnapshots', 'failOnFlaky'])
40
+ if (config[key] !== undefined && typeof config[key] !== 'boolean')
41
+ throw new Error(`${key} must be boolean.`);
42
+ (0, distribution_1.parseShard)(config.shard);
43
+ if (config.ci && config.updateBaselines)
44
+ throw new Error('Baseline updates are not allowed in CI.');
32
45
  if (config.ci && input.cloudSync !== true)
33
46
  config.isCloudSyncActive = false;
34
47
  if (options.headless || config.ci)
@@ -0,0 +1,38 @@
1
+ import type { FlowstrideConfig } from '../types';
2
+ export declare function parseShard(value?: string): {
3
+ index: number;
4
+ total: number;
5
+ };
6
+ export declare function executionPlan(target: string, config?: FlowstrideConfig): {
7
+ files: string[];
8
+ allFiles: string[];
9
+ setupFiles: string[];
10
+ suiteHash: string;
11
+ configHash: string;
12
+ shard: {
13
+ index: number;
14
+ total: number;
15
+ };
16
+ flowRoot: string;
17
+ };
18
+ export declare function mergeResults(summaries: string[], output?: string): {
19
+ protocol: number;
20
+ engine: any;
21
+ browser: any;
22
+ browsers: any[];
23
+ status: string;
24
+ metrics: {
25
+ total: number;
26
+ passed: number;
27
+ failed: number;
28
+ skipped: number;
29
+ incomplete: number;
30
+ };
31
+ steps: any[];
32
+ attempts: any[];
33
+ errors: string[];
34
+ timings: Record<string, number>;
35
+ files: string[];
36
+ duration: number;
37
+ distribution: any;
38
+ };
@@ -0,0 +1,134 @@
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.parseShard = parseShard;
7
+ exports.executionPlan = executionPlan;
8
+ exports.mergeResults = mergeResults;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_crypto_1 = require("node:crypto");
12
+ const projectPaths_1 = require("../utils/projectPaths");
13
+ function parseShard(value) {
14
+ if (value === undefined)
15
+ return { index: 1, total: 1 };
16
+ const match = /^(\d+)\/(\d+)$/.exec(value);
17
+ if (!match)
18
+ throw new Error('shard must be index/total, e.g. 1/4.');
19
+ const index = Number(match[1]), total = Number(match[2]);
20
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 1 || index > total || total > 256)
21
+ throw new Error('Shard index must be between 1 and total (maximum 256).');
22
+ return { index, total };
23
+ }
24
+ function executionPlan(target, config) {
25
+ const root = config?.projectRoot || process.cwd(), files = (0, projectPaths_1.discoverFlows)(target, root);
26
+ const flowRoot = node_fs_1.default.statSync(target).isDirectory() ? target : node_path_1.default.dirname(target);
27
+ const shard = parseShard(config?.shard);
28
+ const identity = (file) => (0, projectPaths_1.flowIdentity)(file, flowRoot);
29
+ const suiteHash = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(files.map(file => [identity(file), (0, node_crypto_1.createHash)('sha256').update(node_fs_1.default.readFileSync(file)).digest('hex')]))).digest('hex');
30
+ // Configuration affects compatibility, but only a hash leaves this process.
31
+ const configHash = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify([require('../../package.json').name, require('../../package.json').version, config?.browser || 'chromium', config?.device || 'desktop', config?.browserOptions || {}, config?.retries || 0, config?.baseUrl || '', config?.timeout || 30000, config?.headless, config?.browserErrors, config?.failOnFlaky, config?.recordVideo, config?.trace, config?.har])).digest('hex');
32
+ let history = {};
33
+ if (config?.shardHistory) {
34
+ history = JSON.parse(node_fs_1.default.readFileSync((0, projectPaths_1.projectPath)(root, config.shardHistory), 'utf8'));
35
+ if (!history || Array.isArray(history) || typeof history !== 'object' || Object.values(history).some(value => typeof value !== 'number' || !Number.isFinite(value) || value < 0))
36
+ throw new Error('Shard history must map file names to nonnegative durations.');
37
+ }
38
+ const setup = files.filter(file => file.endsWith('.first.flow'));
39
+ const standard = files.filter(file => !file.endsWith('.first.flow'));
40
+ const bins = Array.from({ length: shard.total }, () => []), weights = bins.map(() => 0);
41
+ for (const file of [...standard].sort((a, b) => (history[identity(b)] ?? 1) - (history[identity(a)] ?? 1) || identity(a).localeCompare(identity(b)))) {
42
+ const index = weights.indexOf(Math.min(...weights));
43
+ bins[index].push(file);
44
+ weights[index] += history[identity(file)] ?? 1;
45
+ }
46
+ const assigned = new Set([...setup, ...bins[shard.index - 1]]);
47
+ return { files: files.filter(file => assigned.has(file)), allFiles: files.map(identity), setupFiles: setup.map(identity), suiteHash, configHash, shard, flowRoot };
48
+ }
49
+ function mergeResults(summaries, output = 'flowstride-merged') {
50
+ const root = process.cwd(), out = (0, projectPaths_1.projectPath)(root, output);
51
+ if (node_fs_1.default.existsSync(out))
52
+ throw new Error('Merge output already exists; choose a new directory.');
53
+ if (!summaries.length)
54
+ throw new Error('Provide one summary per shard.');
55
+ const inputs = summaries.map(file => { const absolute = (0, projectPaths_1.projectPath)(root, file); return { file: absolute, report: JSON.parse(node_fs_1.default.readFileSync(absolute, 'utf8')) }; });
56
+ const first = inputs[0].report;
57
+ if (first.protocol !== 1 || !first.distribution?.suiteHash)
58
+ throw new Error('Only native distributed reports can be merged.');
59
+ const total = first.distribution.shard.total, seen = new Set(), seenFiles = new Set(), setup = new Set(first.distribution.setupFiles);
60
+ const errors = [], steps = [], attempts = [], timings = {};
61
+ for (const { report } of inputs) {
62
+ const dist = report.distribution;
63
+ if (report.protocol !== 1 || report.engine !== first.engine || !dist || dist.suiteHash !== first.distribution.suiteHash || dist.configHash !== first.distribution.configHash || dist.shard.total !== total || JSON.stringify(dist.allFiles) !== JSON.stringify(first.distribution.allFiles) || JSON.stringify(dist.setupFiles) !== JSON.stringify(first.distribution.setupFiles))
64
+ throw new Error('Shards belong to different suites or browser configurations.');
65
+ const { index } = parseShard(`${dist.shard.index}/${dist.shard.total}`);
66
+ if (seen.has(index))
67
+ throw new Error('Duplicate shard.');
68
+ seen.add(index);
69
+ if (!['passed', 'failed', 'interrupted'].includes(report.status) || !Array.isArray(report.steps) || !Array.isArray(report.files) || !Array.isArray(report.errors))
70
+ throw new Error('Invalid shard report.');
71
+ if (!report.metrics || ['total', 'passed', 'failed', 'skipped', 'incomplete'].some(key => !Number.isSafeInteger(report.metrics[key]) || report.metrics[key] < 0) || report.metrics.passed + report.metrics.failed + report.metrics.skipped + report.metrics.incomplete !== report.metrics.total)
72
+ throw new Error('Inconsistent shard metrics.');
73
+ if (report.status === 'passed' && (report.errors.length || report.metrics.failed || report.metrics.incomplete || report.steps.some((step) => !['passed', 'skipped', 'not_tested'].includes(step.status))))
74
+ throw new Error('A passing shard contains failures or unfinished work.');
75
+ const duplicateSetup = new Set();
76
+ for (const file of report.files) {
77
+ if (!dist.allFiles.includes(file))
78
+ throw new Error('Unexpected file in shard.');
79
+ if (seenFiles.has(file)) {
80
+ if (!setup.has(file))
81
+ throw new Error('File appears in more than one shard.');
82
+ duplicateSetup.add(file);
83
+ }
84
+ seenFiles.add(file);
85
+ }
86
+ for (const step of report.steps)
87
+ steps.push({ ...step, shard: index });
88
+ attempts.push(...(report.attempts || []).map((attempt) => ({ ...attempt, shard: index })));
89
+ errors.push(...report.errors.map((error) => `Shard ${index}: ${error}`));
90
+ for (const [file, duration] of Object.entries(report.timings || {}))
91
+ if (typeof duration === 'number' && duration >= 0)
92
+ timings[file] = Math.max(timings[file] || 0, duration);
93
+ }
94
+ if (seen.size !== total)
95
+ throw new Error(`Missing shards: expected ${total}, received ${seen.size}.`);
96
+ if (first.distribution.allFiles.some((file) => !seenFiles.has(file)))
97
+ throw new Error('Merged reports omit test files.');
98
+ const statuses = inputs.map(input => input.report.status), status = statuses.includes('interrupted') ? 'interrupted' : statuses.includes('failed') || errors.length ? 'failed' : 'passed';
99
+ const metrics = inputs.reduce((all, { report }) => { for (const key of ['total', 'passed', 'failed', 'skipped', 'incomplete']) {
100
+ const n = report.metrics?.[key];
101
+ if (!Number.isSafeInteger(n) || n < 0)
102
+ throw new Error('Invalid shard metrics.');
103
+ all[key] += n;
104
+ } return all; }, { total: 0, passed: 0, failed: 0, skipped: 0, incomplete: 0 });
105
+ // Preserve shard-level metrics (setup executes independently on each machine).
106
+ const merged = { protocol: 1, engine: first.engine, browser: first.browser, browsers: inputs.flatMap(({ report }) => (report.browsers || []).map((info) => ({ ...info, shard: report.distribution.shard.index }))), status, metrics, steps, attempts, errors, timings, files: [...seenFiles], duration: Math.max(...inputs.map(input => input.report.duration)), distribution: { ...first.distribution, merged: true, setupRunsPerShard: true } };
107
+ node_fs_1.default.mkdirSync(out, { recursive: true, mode: 0o700 });
108
+ try {
109
+ for (const { file, report } of inputs) {
110
+ const source = (0, projectPaths_1.projectPath)(root, node_path_1.default.join(node_path_1.default.dirname(file), node_path_1.default.basename(report.artifactsDir || '')));
111
+ if (!node_fs_1.default.existsSync(source) || !node_fs_1.default.statSync(source).isDirectory())
112
+ throw new Error('Shard evidence directory is missing. Preserve each summary alongside its run directory.');
113
+ const destination = node_path_1.default.join(out, `shard-${report.distribution.shard.index}`);
114
+ node_fs_1.default.mkdirSync(destination);
115
+ let bytes = 0;
116
+ for (const name of node_fs_1.default.readdirSync(source)) {
117
+ const item = (0, projectPaths_1.projectPath)(root, node_path_1.default.join(source, name)), stat = node_fs_1.default.lstatSync(item);
118
+ if (!stat.isFile() || stat.isSymbolicLink())
119
+ throw new Error('Shard evidence must contain regular files only.');
120
+ bytes += stat.size;
121
+ if (bytes > 500 * 1024 * 1024)
122
+ throw new Error('Shard evidence exceeds the 500 MiB merge limit.');
123
+ node_fs_1.default.copyFileSync(item, node_path_1.default.join(destination, name));
124
+ }
125
+ }
126
+ node_fs_1.default.writeFileSync(node_path_1.default.join(out, 'summary.json'), JSON.stringify(merged, null, 2));
127
+ node_fs_1.default.writeFileSync(node_path_1.default.join(out, 'timings.json'), JSON.stringify(timings, null, 2));
128
+ return merged;
129
+ }
130
+ catch (error) {
131
+ node_fs_1.default.rmSync(out, { recursive: true, force: true });
132
+ throw error;
133
+ }
134
+ }
package/dist/ci/init.d.ts CHANGED
@@ -1 +1,4 @@
1
- export declare function initCI(provider: string): void;
1
+ export declare function initCI(provider: string, options?: {
2
+ browsers?: string;
3
+ shards?: string;
4
+ }): void;
package/dist/ci/init.js CHANGED
@@ -7,15 +7,26 @@ exports.initCI = initCI;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const config_1 = require("./config");
10
- function initCI(provider) {
10
+ function initCI(provider, options = {}) {
11
11
  if (provider !== 'github')
12
12
  throw new Error('Supported provider: github.');
13
+ const browsers = (options.browsers || 'chromium').split(',');
14
+ const shards = Number(options.shards || 1);
15
+ if (!browsers.length || new Set(browsers).size !== browsers.length || browsers.some(browser => !['chromium', 'firefox', 'webkit'].includes(browser)))
16
+ throw new Error('Choose unique browser names separated by commas.');
17
+ if (!Number.isSafeInteger(shards) || shards < 1 || shards > 256)
18
+ throw new Error('shards must be 1..256.');
19
+ const matrix = browsers.length > 1 || shards > 1;
20
+ const strategy = matrix ? `\n strategy:\n fail-fast: false\n matrix:\n browser: ${JSON.stringify(browsers)}\n shard: ${JSON.stringify(Array.from({ length: shards }, (_, index) => index + 1))}` : '';
21
+ const selection = matrix ? ' --browser ${{ matrix.browser }} --shard ${{ matrix.shard }}/' + shards : browsers[0] !== 'chromium' ? ' --browser ' + browsers[0] : '';
22
+ const install = matrix ? ' ${{ matrix.browser }}' : ' ' + browsers[0];
23
+ const artifact = matrix ? 'flowstride-results-${{ matrix.browser }}-${{ matrix.shard }}' : 'flowstride-results';
13
24
  const file = node_path_1.default.resolve('.github/workflows/flowstride.yml');
14
25
  if (node_fs_1.default.existsSync(file))
15
26
  throw new Error('Flowstride workflow already exists; it was not overwritten.');
16
27
  const artifacts = node_path_1.default.relative(process.cwd(), (0, config_1.loadNativeConfig)(process.cwd(), {}).artifactsDir);
17
28
  const parent = node_path_1.default.dirname(file);
18
29
  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' });
30
+ 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${strategy}\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${install}\n - name: Run native Flowstride\n run: npx --no-install flowstride run --ci${selection}\n - name: Retain test evidence\n if: always()\n uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n with:\n name: ${artifact}\n path: ${JSON.stringify(artifacts + "/")}\n retention-days: 14\n`, { flag: 'wx' });
20
31
  console.log(`Created ${file}. Commit it when ready. This template uses the project-locked Flowstride installation.`);
21
32
  }