flowstride 1.0.20-rc.6 → 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 (51) hide show
  1. package/NATIVE_BROWSER.md +341 -0
  2. package/NATIVE_CI.md +88 -169
  3. package/README.md +46 -214
  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 +25 -2
  7. package/dist/adapters/web/playwright.js +285 -21
  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 +141 -37
  43. package/dist/security/redaction.js +3 -1
  44. package/dist/sessions/manager.js +3 -1
  45. package/dist/types/index.d.ts +12 -0
  46. package/dist/utils/replayRequest.d.ts +8 -0
  47. package/dist/utils/replayRequest.js +77 -0
  48. package/package.json +22 -9
  49. package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
  50. package/src/ui/dist/index.html +1 -1
  51. package/src/ui/dist/assets/index-DVwp0K-J.js +0 -462
@@ -37,21 +37,194 @@ 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;
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
+ }
226
+ }
227
+ }
55
228
  constructor(config) {
56
229
  this.config = config;
57
230
  }
@@ -60,7 +233,13 @@ class WebAdapter {
60
233
  throw new Error("Browser not initialised");
61
234
  return this.page;
62
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
+ }
63
241
  async initialise(audioInjectionPath) {
242
+ await this.clearSessionStorageRestore();
64
243
  const launchArgs = this.config.headless
65
244
  ? [
66
245
  "--use-fake-ui-for-media-stream",
@@ -76,23 +255,48 @@ class WebAdapter {
76
255
  if (audioInjectionPath) {
77
256
  launchArgs.push(`--use-file-for-fake-audio-capture=${audioInjectionPath}`);
78
257
  }
79
- 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({
80
273
  headless: this.config.headless,
81
274
  // Native CI finalizes videos before closing the browser on cancellation.
82
275
  ...(this.config.ci ? { handleSIGINT: false, handleSIGTERM: false } : {}),
83
- args: launchArgs,
276
+ args: !this.config.browser || this.config.browser === 'chromium' ? launchArgs : [],
84
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
+ }
85
283
  const videoDir = (0, projectPaths_1.projectPath)(process.cwd(), path.join('.flowstride', 'videos'));
86
284
  if (!fs.existsSync(videoDir))
87
285
  fs.mkdirSync(videoDir, { recursive: true });
88
286
  const profile = this.config.device === 'phone' || this.config.device === 'landscape'
89
- ? { ...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 } }
90
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;
91
291
  const viewport = this.config.viewport || profile.viewport;
92
- this.context = await this.browser.newContext({
292
+ this.contextOptions = {
93
293
  ...profile, viewport,
294
+ ...(this.config.browser === 'firefox' ? { isMobile: false } : {}),
295
+ ...(0, validation_1.validateContextOptions)(this.config.browserOptions, this.config.browser, this.config.projectRoot),
94
296
  ...(this.config.recordVideo === false ? {} : { recordVideo: { dir: videoDir, size: viewport } }),
95
- });
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' } } : {}) });
96
300
  this.pageErrors = [];
97
301
  this.context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
98
302
  this.context.on("dialog", async (dialog) => {
@@ -114,6 +318,9 @@ class WebAdapter {
114
318
  });
115
319
  this.page = await this.context.newPage();
116
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();
117
324
  }
118
325
  async acceptDialog() {
119
326
  this.dialogHandler = async (dialog) => {
@@ -183,9 +390,26 @@ class WebAdapter {
183
390
  await this.page.bringToFront();
184
391
  await this.page.waitForLoadState("domcontentloaded").catch(() => { });
185
392
  }
186
- async injectStorage(cookies, localStorage, fallbackDomain) {
393
+ async injectStorage(cookies, localStorage, fallbackDomain, sessionStorage, storageOrigin) {
187
394
  if (!this.context || !this.page)
188
395
  return;
396
+ await this.clearSessionStorageRestore(this.page);
397
+ const page = this.page;
398
+ const originOf = (value) => {
399
+ try {
400
+ const url = new URL(value || '');
401
+ return ['http:', 'https:'].includes(url.protocol) ? url.origin : undefined;
402
+ }
403
+ catch {
404
+ return undefined;
405
+ }
406
+ };
407
+ const hasSessionStorage = !!sessionStorage && Object.keys(sessionStorage).length > 0;
408
+ const origin = storageOrigin !== undefined ? originOf(storageOrigin)
409
+ : originOf(page.url()) || originOf(this.config.baseUrl);
410
+ if (hasSessionStorage && !origin) {
411
+ throw new Error('Session storage restoration requires a saved HTTP(S) origin, an open application page, or baseUrl.');
412
+ }
189
413
  if (cookies && cookies.length > 0) {
190
414
  const formattedCookies = [];
191
415
  for (const cookie of cookies) {
@@ -232,6 +456,32 @@ class WebAdapter {
232
456
  }, localStorage);
233
457
  }
234
458
  }
459
+ if (hasSessionStorage) {
460
+ const restore = (data) => {
461
+ if (window !== window.top || window.location.origin !== data.origin)
462
+ return;
463
+ for (const [key, value] of Object.entries(data.values))
464
+ window.sessionStorage.setItem(key, value);
465
+ };
466
+ const data = { origin: origin, values: sessionStorage };
467
+ if (originOf(page.url()) === origin) {
468
+ await page.evaluate(restore, data);
469
+ }
470
+ else {
471
+ // Seed before application scripts run; remove the seed after the first
472
+ // matching document so reloads preserve later application changes.
473
+ const script = await page.addInitScript(restore, data);
474
+ const onNavigation = (frame) => {
475
+ if (frame !== page.mainFrame() || originOf(frame.url()) !== origin)
476
+ return;
477
+ void this.clearSessionStorageRestore(page).catch(() => {
478
+ console.warn('[Session]: Could not remove the completed session storage initializer.');
479
+ });
480
+ };
481
+ this.sessionStorageRestores.set(page, { page, script, onNavigation });
482
+ page.on('framenavigated', onNavigation);
483
+ }
484
+ }
235
485
  }
236
486
  async getState() {
237
487
  const cookies = (await this.context?.cookies()) || [];
@@ -250,7 +500,14 @@ class WebAdapter {
250
500
  console.warn("Warning: Could not access storage. Ensure page is loaded.");
251
501
  }
252
502
  }
253
- return { cookies, localStorage, sessionStorage };
503
+ let storageOrigin;
504
+ try {
505
+ const url = new URL(this.page?.url() || '');
506
+ if (['http:', 'https:'].includes(url.protocol))
507
+ storageOrigin = url.origin;
508
+ }
509
+ catch { }
510
+ return { cookies, localStorage, sessionStorage, storageOrigin };
254
511
  }
255
512
  getCurrentUrl() {
256
513
  return this.page ? this.page.url() : "about:blank";
@@ -1195,23 +1452,30 @@ class WebAdapter {
1195
1452
  }, { id, text, status, error });
1196
1453
  }
1197
1454
  async shutdown(videoTargetPath) {
1455
+ const failures = [];
1198
1456
  try {
1199
- const video = this.page?.video();
1200
- if (this.context) {
1201
- await this.context.close();
1202
- }
1203
- if (video && videoTargetPath) {
1204
- await video.saveAs(videoTargetPath);
1457
+ await this.clearSessionStorageRestore();
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
+ }
1205
1468
  }
1206
1469
  }
1207
- catch (err) {
1208
- console.warn("[WebAdapter]: Warning during browser shutdown or video transfer.");
1209
- }
1210
1470
  finally {
1211
- if (this.browser) {
1212
- await this.browser.close().catch(() => { });
1213
- }
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;
1214
1476
  }
1477
+ if (failures.length)
1478
+ throw new AggregateError(failures, 'Browser evidence finalization failed.');
1215
1479
  }
1216
1480
  }
1217
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
  }