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.
- package/NATIVE_BROWSER.md +341 -0
- package/NATIVE_CI.md +3 -3
- package/README.md +3 -3
- package/dist/adapters/api/undici.d.ts +8 -0
- package/dist/adapters/api/undici.js +11 -0
- package/dist/adapters/web/playwright.d.ts +22 -2
- package/dist/adapters/web/playwright.js +233 -36
- package/dist/ci/config.js +14 -1
- package/dist/ci/distribution.d.ts +38 -0
- package/dist/ci/distribution.js +134 -0
- package/dist/ci/init.d.ts +4 -1
- package/dist/ci/init.js +13 -2
- package/dist/ci/runtime.js +24 -5
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/index.js +24 -5
- package/dist/manual/security.d.ts +2 -2
- package/dist/native/accessibility.d.ts +3 -0
- package/dist/native/accessibility.js +68 -0
- package/dist/native/assertions.d.ts +2 -0
- package/dist/native/assertions.js +140 -0
- package/dist/native/compatibility.d.ts +3 -0
- package/dist/native/compatibility.js +16 -0
- package/dist/native/components.d.ts +12 -0
- package/dist/native/components.js +122 -0
- package/dist/native/dashboard.d.ts +7 -0
- package/dist/native/dashboard.js +32 -0
- package/dist/native/events.d.ts +34 -0
- package/dist/native/events.js +281 -0
- package/dist/native/evidence.d.ts +3 -0
- package/dist/native/evidence.js +31 -0
- package/dist/native/functions.d.ts +9 -0
- package/dist/native/functions.js +103 -0
- package/dist/native/runtime.d.ts +14 -0
- package/dist/native/runtime.js +154 -0
- package/dist/native/validation.d.ts +107 -0
- package/dist/native/validation.js +84 -0
- package/dist/parser/lexer.js +15 -4
- package/dist/parser/parser.js +1 -1
- package/dist/runner/flow_orchestrator.d.ts +4 -0
- package/dist/runner/flow_orchestrator.js +22 -5
- package/dist/runner/flow_worker.d.ts +7 -0
- package/dist/runner/flow_worker.js +102 -3
- package/dist/types/index.d.ts +10 -0
- package/package.json +20 -9
- package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
- package/src/ui/dist/index.html +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
? { ...
|
|
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.
|
|
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.
|
|
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
|
|
1267
|
-
if (this.
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
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
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:
|
|
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
|
}
|