flowstride 1.0.20 → 1.0.22

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 (99) hide show
  1. package/NATIVE_BROWSER.md +381 -0
  2. package/NATIVE_CI.md +17 -5
  3. package/NATIVE_WORKFLOWS.md +44 -0
  4. package/README.md +11 -4
  5. package/dist/adapters/api/undici.d.ts +11 -1
  6. package/dist/adapters/api/undici.js +28 -3
  7. package/dist/adapters/web/playwright.d.ts +22 -2
  8. package/dist/adapters/web/playwright.js +254 -107
  9. package/dist/ci/config.js +14 -1
  10. package/dist/ci/distribution.d.ts +38 -0
  11. package/dist/ci/distribution.js +134 -0
  12. package/dist/ci/init.d.ts +4 -1
  13. package/dist/ci/init.js +13 -2
  14. package/dist/ci/runtime.js +33 -6
  15. package/dist/cli/commands/run.js +17 -4
  16. package/dist/cli/index.js +24 -5
  17. package/dist/cli/manual_server/workspace_server.js +90 -74
  18. package/dist/manual/security.d.ts +6 -6
  19. package/dist/native/accessibility.d.ts +4 -0
  20. package/dist/native/accessibility.js +84 -0
  21. package/dist/native/assertions.d.ts +3 -0
  22. package/dist/native/assertions.js +223 -0
  23. package/dist/native/compatibility.d.ts +3 -0
  24. package/dist/native/compatibility.js +16 -0
  25. package/dist/native/components.d.ts +12 -0
  26. package/dist/native/components.js +122 -0
  27. package/dist/native/dashboard.d.ts +7 -0
  28. package/dist/native/dashboard.js +32 -0
  29. package/dist/native/deadline.d.ts +2 -0
  30. package/dist/native/deadline.js +13 -0
  31. package/dist/native/events.d.ts +35 -0
  32. package/dist/native/events.js +308 -0
  33. package/dist/native/evidence.d.ts +3 -0
  34. package/dist/native/evidence.js +31 -0
  35. package/dist/native/functions.d.ts +10 -0
  36. package/dist/native/functions.js +131 -0
  37. package/dist/native/runtime.d.ts +17 -0
  38. package/dist/native/runtime.js +160 -0
  39. package/dist/native/validation.d.ts +107 -0
  40. package/dist/native/validation.js +84 -0
  41. package/dist/parser/assertion_contract.d.ts +12 -0
  42. package/dist/parser/assertion_contract.js +56 -0
  43. package/dist/parser/lexer.js +15 -4
  44. package/dist/parser/parser.js +15 -7
  45. package/dist/reporting/assertion_result.d.ts +36 -0
  46. package/dist/reporting/assertion_result.js +104 -0
  47. package/dist/reporting/pdf_generator.js +25 -20
  48. package/dist/reporting/runtime_failure.d.ts +10 -0
  49. package/dist/reporting/runtime_failure.js +64 -0
  50. package/dist/reporting/step_status.d.ts +10 -0
  51. package/dist/reporting/step_status.js +44 -0
  52. package/dist/runner/flow_orchestrator.d.ts +5 -0
  53. package/dist/runner/flow_orchestrator.js +30 -8
  54. package/dist/runner/flow_worker.d.ts +15 -1
  55. package/dist/runner/flow_worker.js +219 -42
  56. package/dist/runner/plugin_manager.js +3 -0
  57. package/dist/runner/temporary_store.d.ts +1 -0
  58. package/dist/runner/temporary_store.js +3 -0
  59. package/dist/sdk/api_proxy.d.ts +3 -0
  60. package/dist/sdk/api_proxy.js +16 -0
  61. package/dist/security/localWorkspace.d.ts +2 -0
  62. package/dist/security/localWorkspace.js +17 -0
  63. package/dist/security/redaction.js +1 -1
  64. package/dist/types/index.d.ts +13 -0
  65. package/examples/native-workflows/README.md +144 -0
  66. package/examples/native-workflows/api-and-ui/README.md +80 -0
  67. package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
  68. package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
  69. package/examples/native-workflows/api-ui-otp/README.md +140 -0
  70. package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
  71. package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
  72. package/examples/native-workflows/audio-to-text/README.md +131 -0
  73. package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
  74. package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
  75. package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
  76. package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
  77. package/examples/native-workflows/iframes/README.md +116 -0
  78. package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
  79. package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
  80. package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
  81. package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
  82. package/examples/native-workflows/manual-testing/README.md +85 -0
  83. package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
  84. package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
  85. package/examples/native-workflows/shared/server.mjs +80 -0
  86. package/examples/native-workflows/tables-and-cells/README.md +118 -0
  87. package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
  88. package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
  89. package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
  90. package/examples/native-workflows/variables-and-reuse/README.md +140 -0
  91. package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
  92. package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
  93. package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
  94. package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
  95. package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
  96. package/package.json +23 -9
  97. package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
  98. package/src/ui/dist/index.html +1 -1
  99. package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
@@ -37,34 +37,195 @@ 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
+ for (const page of profile.context.pages())
137
+ await this.clearSessionStorageRestore(page);
138
+ if (profile.tracing)
139
+ await this.endTrace(key);
140
+ const video = profile.page.video();
141
+ await profile.context.close();
142
+ if (video) {
143
+ const target = videoTargetPath || (key !== 'default' ? (0, validation_1.evidencePath)(this.config, `${key}-${Date.now()}.webm`) : undefined);
144
+ if (target)
145
+ await video.saveAs(target);
146
+ }
147
+ }
148
+ async checkpointProfiles() {
149
+ const result = { active: this.activeProfile, dialogHandler: this.dialogHandler, profiles: [] };
150
+ const current = this.profiles.get(this.activeProfile);
151
+ if (current && this.page)
152
+ current.page = this.page;
153
+ for (const [key, profile] of this.profiles) {
154
+ const pages = [];
155
+ for (const page of profile.context.pages())
156
+ pages.push({ url: page.url(), active: page === profile.page,
157
+ sessionStorage: await page.evaluate(() => ({ ...window.sessionStorage })).catch(() => ({})) });
158
+ result.profiles.push({ key, options: profile.options, storageState: await profile.context.storageState({ indexedDB: true }), pages });
159
+ }
160
+ return result;
161
+ }
162
+ async restoreProfiles(snapshot) {
163
+ await this.clearSessionStorageRestore();
164
+ for (const [key] of this.profiles)
165
+ await this.finishProfile(key, (0, validation_1.evidencePath)(this.config, `${key}-${Date.now()}-attempt.webm`));
166
+ this.profiles.clear();
167
+ this.dialogHandler = snapshot.dialogHandler;
168
+ for (const saved of snapshot.profiles) {
169
+ const context = await this.browser.newContext({ ...saved.options, storageState: saved.storageState,
170
+ ...(this.config.har ? { recordHar: { path: (0, validation_1.evidencePath)(this.config, `${saved.key}-${Date.now()}-retry.har`), content: 'embed' } } : {}) });
171
+ context.setDefaultTimeout(this.config.timeout);
172
+ context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
173
+ context.on('dialog', async (dialog) => { if (this.dialogHandler)
174
+ await this.dialogHandler(dialog);
175
+ else if (this.config.defaultDialogBehavior === 'accept')
176
+ await dialog.accept().catch(() => { });
177
+ else
178
+ await dialog.dismiss().catch(() => { }); });
179
+ let selected;
180
+ for (const tab of saved.pages) {
181
+ const page = await context.newPage();
182
+ if (tab.active || !selected)
183
+ selected = page;
184
+ if (/^https?:/.test(tab.url)) {
185
+ const origin = new URL(tab.url).origin;
186
+ const seed = await page.addInitScript(({ origin, values }) => { if (window === window.top && location.origin === origin)
187
+ for (const [key, value] of Object.entries(values))
188
+ sessionStorage.setItem(key, String(value)); }, { origin, values: tab.sessionStorage });
189
+ try {
190
+ await page.goto(tab.url, { waitUntil: 'domcontentloaded' });
191
+ }
192
+ finally {
193
+ await seed.dispose();
194
+ }
195
+ }
196
+ }
197
+ selected ||= await context.newPage();
198
+ this.profiles.set(saved.key, { context, page: selected, options: saved.options, tracing: false });
199
+ if (this.config.trace)
200
+ await this.beginTrace(saved.key);
201
+ }
202
+ this.context = null;
203
+ this.page = null;
204
+ await this.useProfile(snapshot.active);
205
+ }
49
206
  pageErrors = [];
50
207
  browser = null;
51
208
  context = null;
52
209
  page = null;
53
210
  discoveredPages = [];
54
211
  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;
212
+ // One-shot initializers for both Web Storage types, owned by their exact page.
213
+ sessionStorageRestores = new Map();
214
+ get pendingSessionStorageRestore() { return this.sessionStorageRestores.values().next().value || null; }
215
+ async clearSessionStorageRestore(target) {
216
+ for (const [page, pending] of this.sessionStorageRestores) {
217
+ if (target && page !== target)
218
+ continue;
219
+ this.sessionStorageRestores.delete(page);
220
+ pending.page.off('framenavigated', pending.onNavigation);
221
+ pending.page.off('close', pending.onClose);
222
+ try {
223
+ await pending.script.dispose();
224
+ }
225
+ catch (error) {
226
+ if (!pending.page.isClosed())
227
+ throw error;
228
+ }
68
229
  }
69
230
  }
70
231
  constructor(config) {
@@ -75,6 +236,11 @@ class WebAdapter {
75
236
  throw new Error("Browser not initialised");
76
237
  return this.page;
77
238
  }
239
+ getBrowserInfo() {
240
+ if (!this.browser)
241
+ throw new Error('Browser not initialised');
242
+ return { engine: this.browser.browserType().name(), version: this.browser.version(), headless: !!this.config.headless };
243
+ }
78
244
  async initialise(audioInjectionPath) {
79
245
  await this.clearSessionStorageRestore();
80
246
  const launchArgs = this.config.headless
@@ -92,23 +258,48 @@ class WebAdapter {
92
258
  if (audioInjectionPath) {
93
259
  launchArgs.push(`--use-file-for-fake-audio-capture=${audioInjectionPath}`);
94
260
  }
95
- this.browser = await playwright_extra_1.chromium.launch({
261
+ let engine = this.config.browser === 'firefox' ? playwright_1.firefox : this.config.browser === 'webkit' ? playwright_1.webkit : chromium;
262
+ if ((!this.config.browser || this.config.browser === 'chromium') && this.config.browserOptions?.userAgent) {
263
+ const configuredChromium = (0, playwright_extra_1.addExtra)(playwright_1.chromium);
264
+ const stealth = (0, puppeteer_extra_plugin_stealth_1.default)();
265
+ stealth.enabledEvasions.delete('user-agent-override');
266
+ configuredChromium.use(stealth);
267
+ engine = configuredChromium;
268
+ }
269
+ if (audioInjectionPath && this.config.browser && this.config.browser !== 'chromium')
270
+ throw new Error('Audio file injection requires Chromium.');
271
+ this.profiles.clear();
272
+ this.activeProfile = 'default';
273
+ this.discoveredPages = [];
274
+ this.dialogHandler = null;
275
+ this.browser = await engine.launch({
96
276
  headless: this.config.headless,
97
277
  // Native CI finalizes videos before closing the browser on cancellation.
98
278
  ...(this.config.ci ? { handleSIGINT: false, handleSIGTERM: false } : {}),
99
- args: launchArgs,
279
+ args: !this.config.browser || this.config.browser === 'chromium' ? launchArgs : [],
100
280
  });
281
+ if (this.browser.browserType().name() !== (this.config.browser || 'chromium')) {
282
+ await this.browser.close();
283
+ this.browser = null;
284
+ throw new Error('The launched browser does not match the requested browser.');
285
+ }
101
286
  const videoDir = (0, projectPaths_1.projectPath)(process.cwd(), path.join('.flowstride', 'videos'));
102
287
  if (!fs.existsSync(videoDir))
103
288
  fs.mkdirSync(videoDir, { recursive: true });
104
289
  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 } }
290
+ ? { ...playwright_2.devices['Pixel 7'], viewport: this.config.device === 'landscape' ? { width: 844, height: 390 } : { width: 390, height: 844 } }
106
291
  : { 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' };
292
+ if (this.config.browser && this.config.browser !== 'chromium')
293
+ delete profile.userAgent;
107
294
  const viewport = this.config.viewport || profile.viewport;
108
- this.context = await this.browser.newContext({
295
+ this.contextOptions = {
109
296
  ...profile, viewport,
297
+ ...(this.config.browser === 'firefox' ? { isMobile: false } : {}),
298
+ ...(0, validation_1.validateContextOptions)(this.config.browserOptions, this.config.browser, this.config.projectRoot),
110
299
  ...(this.config.recordVideo === false ? {} : { recordVideo: { dir: videoDir, size: viewport } }),
111
- });
300
+ };
301
+ this.context = await this.browser.newContext({ ...this.contextOptions,
302
+ ...(this.config.har ? { recordHar: { path: (0, validation_1.evidencePath)(this.config, `default-${Date.now()}.har`), content: 'embed', mode: 'full' } } : {}) });
112
303
  this.pageErrors = [];
113
304
  this.context.on('page', page => page.on('pageerror', error => this.pageErrors.push(error.message)));
114
305
  this.context.on("dialog", async (dialog) => {
@@ -130,6 +321,9 @@ class WebAdapter {
130
321
  });
131
322
  this.page = await this.context.newPage();
132
323
  this.page.setDefaultTimeout(this.config.timeout);
324
+ this.profiles.set('default', { context: this.context, page: this.page, options: this.contextOptions, tracing: false });
325
+ if (this.config.trace)
326
+ await this.beginTrace();
133
327
  }
134
328
  async acceptDialog() {
135
329
  this.dialogHandler = async (dialog) => {
@@ -202,7 +396,7 @@ class WebAdapter {
202
396
  async injectStorage(cookies, localStorage, fallbackDomain, sessionStorage, storageOrigin) {
203
397
  if (!this.context || !this.page)
204
398
  return;
205
- await this.clearSessionStorageRestore();
399
+ await this.clearSessionStorageRestore(this.page);
206
400
  const page = this.page;
207
401
  const originOf = (value) => {
208
402
  try {
@@ -213,10 +407,11 @@ class WebAdapter {
213
407
  return undefined;
214
408
  }
215
409
  };
410
+ const hasLocalStorage = !!localStorage && Object.keys(localStorage).length > 0;
216
411
  const hasSessionStorage = !!sessionStorage && Object.keys(sessionStorage).length > 0;
217
412
  const origin = storageOrigin !== undefined ? originOf(storageOrigin)
218
- : originOf(page.url()) || originOf(this.config.baseUrl);
219
- if (hasSessionStorage && !origin) {
413
+ : originOf(this.config.baseUrl) || originOf(page.url());
414
+ if ((hasLocalStorage || hasSessionStorage) && !origin) {
220
415
  throw new Error('Session storage restoration requires a saved HTTP(S) origin, an open application page, or baseUrl.');
221
416
  }
222
417
  if (cookies && cookies.length > 0) {
@@ -249,46 +444,36 @@ class WebAdapter {
249
444
  }
250
445
  await this.context.addCookies(formattedCookies);
251
446
  }
252
- if (localStorage && Object.keys(localStorage).length > 0) {
253
- await this.page.addInitScript((data) => {
254
- for (const [k, v] of Object.entries(data)) {
255
- window.localStorage.setItem(k, v);
256
- }
257
- }, localStorage);
258
- if (this.page.url() !== "about:blank") {
259
- await this.page.evaluate((data) => {
260
- try {
261
- for (const [k, v] of Object.entries(data))
262
- window.localStorage.setItem(k, v);
263
- }
264
- catch (e) { }
265
- }, localStorage);
266
- }
267
- }
268
- if (hasSessionStorage) {
447
+ if (hasLocalStorage || hasSessionStorage) {
269
448
  const restore = (data) => {
449
+ // A matching iframe inside another site must never receive saved secrets.
270
450
  if (window !== window.top || window.location.origin !== data.origin)
271
451
  return;
272
- for (const [key, value] of Object.entries(data.values))
452
+ for (const [key, value] of Object.entries(data.local))
453
+ window.localStorage.setItem(key, value);
454
+ for (const [key, value] of Object.entries(data.session))
273
455
  window.sessionStorage.setItem(key, value);
274
456
  };
275
- const data = { origin: origin, values: sessionStorage };
457
+ const data = { origin: origin, local: localStorage || {}, session: sessionStorage || {} };
276
458
  if (originOf(page.url()) === origin) {
459
+ // Already on the saved origin: restore now without a persistent initializer.
277
460
  await page.evaluate(restore, data);
278
461
  }
279
462
  else {
280
- // Seed before application scripts run; remove the seed after the first
281
- // matching document so reloads preserve later application changes.
463
+ // Seed before application scripts run; remove after the first matching
464
+ // main document so later reloads preserve application changes and logouts.
282
465
  const script = await page.addInitScript(restore, data);
283
466
  const onNavigation = (frame) => {
284
467
  if (frame !== page.mainFrame() || originOf(frame.url()) !== origin)
285
468
  return;
286
- void this.clearSessionStorageRestore().catch(() => {
287
- console.warn('[Session]: Could not remove the completed session storage initializer.');
469
+ void this.clearSessionStorageRestore(page).catch(() => {
470
+ console.warn('[Session]: Could not remove the completed storage initializer.');
288
471
  });
289
472
  };
290
- this.pendingSessionStorageRestore = { page, script, onNavigation };
473
+ const onClose = () => { void this.clearSessionStorageRestore(page).catch(() => { }); };
474
+ this.sessionStorageRestores.set(page, { page, script, onNavigation, onClose });
291
475
  page.on('framenavigated', onNavigation);
476
+ page.on('close', onClose);
292
477
  }
293
478
  }
294
479
  }
@@ -487,53 +672,9 @@ class WebAdapter {
487
672
  const targetLoc = await this.checkAmbiguity(loc, selector);
488
673
  await targetLoc.waitFor({ state: "visible" });
489
674
  await targetLoc.scrollIntoViewIfNeeded();
490
- await targetLoc.focus({ timeout: 3000 }).catch(() => { });
491
- await targetLoc
492
- .evaluate((node) => {
493
- if (typeof node.focus === "function")
494
- node.focus();
495
- })
496
- .catch(() => { });
497
- let box = null;
498
- try {
499
- box = await targetLoc.boundingBox({ timeout: 3000 });
500
- }
501
- catch (e) { }
502
- if (box) {
503
- const centerX = box.x + box.width / 2;
504
- const centerY = box.y + box.height / 2;
505
- await this.page.mouse.move(centerX, centerY, { steps: 5 });
506
- }
507
- await targetLoc.hover({ force: true, timeout: 3000 }).catch(() => { });
508
- await targetLoc
509
- .evaluate((node) => {
510
- const rect = node.getBoundingClientRect();
511
- const x = rect.left + rect.width / 2;
512
- const y = rect.top + rect.height / 2;
513
- const events = [
514
- "pointerover",
515
- "pointerenter",
516
- "mouseover",
517
- "mouseenter",
518
- "mousemove",
519
- ];
520
- events.forEach((type) => {
521
- node.dispatchEvent(new MouseEvent(type, {
522
- view: window,
523
- bubbles: true,
524
- cancelable: true,
525
- clientX: x,
526
- clientY: y,
527
- screenX: x,
528
- screenY: y,
529
- }));
530
- });
531
- })
532
- .catch(() => { });
533
- await targetLoc
534
- .click({ delay: 50, force: true, timeout: 3000 })
535
- .catch(() => { });
536
- await this.page.waitForTimeout(600);
675
+ // A real pointer hover preserves browser actionability checks and must not
676
+ // focus, click, or synthesize duplicate application events.
677
+ await targetLoc.hover();
537
678
  }
538
679
  async type(selector, value, elementType) {
539
680
  const loc = await this.getLocator(selector, elementType, false, false);
@@ -1261,24 +1402,30 @@ class WebAdapter {
1261
1402
  }, { id, text, status, error });
1262
1403
  }
1263
1404
  async shutdown(videoTargetPath) {
1405
+ const failures = [];
1264
1406
  try {
1265
1407
  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);
1408
+ const active = this.profiles.get(this.activeProfile);
1409
+ if (active && this.page)
1410
+ active.page = this.page;
1411
+ for (const [key] of this.profiles) {
1412
+ try {
1413
+ await this.finishProfile(key, key === 'default' ? videoTargetPath : undefined);
1414
+ }
1415
+ catch (error) {
1416
+ failures.push(error);
1417
+ }
1272
1418
  }
1273
1419
  }
1274
- catch (err) {
1275
- console.warn("[WebAdapter]: Warning during browser shutdown or video transfer.");
1276
- }
1277
1420
  finally {
1278
- if (this.browser) {
1279
- await this.browser.close().catch(() => { });
1280
- }
1281
- }
1421
+ await this.browser?.close().catch(error => failures.push(error));
1422
+ this.profiles.clear();
1423
+ this.context = null;
1424
+ this.page = null;
1425
+ this.browser = null;
1426
+ }
1427
+ if (failures.length)
1428
+ throw new AggregateError(failures, 'Browser evidence finalization failed.');
1282
1429
  }
1283
1430
  }
1284
1431
  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
+ };