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.
- package/NATIVE_BROWSER.md +381 -0
- package/NATIVE_CI.md +17 -5
- package/NATIVE_WORKFLOWS.md +44 -0
- package/README.md +11 -4
- package/dist/adapters/api/undici.d.ts +11 -1
- package/dist/adapters/api/undici.js +28 -3
- package/dist/adapters/web/playwright.d.ts +22 -2
- package/dist/adapters/web/playwright.js +254 -107
- 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 +33 -6
- package/dist/cli/commands/run.js +17 -4
- package/dist/cli/index.js +24 -5
- package/dist/cli/manual_server/workspace_server.js +90 -74
- package/dist/manual/security.d.ts +6 -6
- package/dist/native/accessibility.d.ts +4 -0
- package/dist/native/accessibility.js +84 -0
- package/dist/native/assertions.d.ts +3 -0
- package/dist/native/assertions.js +223 -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/deadline.d.ts +2 -0
- package/dist/native/deadline.js +13 -0
- package/dist/native/events.d.ts +35 -0
- package/dist/native/events.js +308 -0
- package/dist/native/evidence.d.ts +3 -0
- package/dist/native/evidence.js +31 -0
- package/dist/native/functions.d.ts +10 -0
- package/dist/native/functions.js +131 -0
- package/dist/native/runtime.d.ts +17 -0
- package/dist/native/runtime.js +160 -0
- package/dist/native/validation.d.ts +107 -0
- package/dist/native/validation.js +84 -0
- package/dist/parser/assertion_contract.d.ts +12 -0
- package/dist/parser/assertion_contract.js +56 -0
- package/dist/parser/lexer.js +15 -4
- package/dist/parser/parser.js +15 -7
- package/dist/reporting/assertion_result.d.ts +36 -0
- package/dist/reporting/assertion_result.js +104 -0
- package/dist/reporting/pdf_generator.js +25 -20
- package/dist/reporting/runtime_failure.d.ts +10 -0
- package/dist/reporting/runtime_failure.js +64 -0
- package/dist/reporting/step_status.d.ts +10 -0
- package/dist/reporting/step_status.js +44 -0
- package/dist/runner/flow_orchestrator.d.ts +5 -0
- package/dist/runner/flow_orchestrator.js +30 -8
- package/dist/runner/flow_worker.d.ts +15 -1
- package/dist/runner/flow_worker.js +219 -42
- package/dist/runner/plugin_manager.js +3 -0
- package/dist/runner/temporary_store.d.ts +1 -0
- package/dist/runner/temporary_store.js +3 -0
- package/dist/sdk/api_proxy.d.ts +3 -0
- package/dist/sdk/api_proxy.js +16 -0
- package/dist/security/localWorkspace.d.ts +2 -0
- package/dist/security/localWorkspace.js +17 -0
- package/dist/security/redaction.js +1 -1
- package/dist/types/index.d.ts +13 -0
- package/examples/native-workflows/README.md +144 -0
- package/examples/native-workflows/api-and-ui/README.md +80 -0
- package/examples/native-workflows/api-and-ui/flows/api-and-ui.flow +27 -0
- package/examples/native-workflows/api-and-ui/negative/wrong-backend-state.flow +27 -0
- package/examples/native-workflows/api-ui-otp/README.md +140 -0
- package/examples/native-workflows/api-ui-otp/templates/api-ui-otp.flow +31 -0
- package/examples/native-workflows/api-ui-otp/templates/existing-account-otp.flow +20 -0
- package/examples/native-workflows/audio-to-text/README.md +131 -0
- package/examples/native-workflows/audio-to-text/fixtures/microphone-tone.wav +0 -0
- package/examples/native-workflows/audio-to-text/flows/microphone-demo.flow +23 -0
- package/examples/native-workflows/audio-to-text/negative/wrong-transcript.flow +23 -0
- package/examples/native-workflows/audio-to-text/templates/real-transcription.flow +25 -0
- package/examples/native-workflows/iframes/README.md +116 -0
- package/examples/native-workflows/iframes/flows/explicit-frame-plugin.flow +20 -0
- package/examples/native-workflows/iframes/flows/readable-frame.flow +13 -0
- package/examples/native-workflows/iframes/negative/wrong-frame-result.flow +13 -0
- package/examples/native-workflows/iframes/plugins/frame-actions.ts +7 -0
- package/examples/native-workflows/manual-testing/README.md +85 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review-mismatch.json +93 -0
- package/examples/native-workflows/manual-testing/fixtures/profile-review.json +93 -0
- package/examples/native-workflows/shared/server.mjs +80 -0
- package/examples/native-workflows/tables-and-cells/README.md +118 -0
- package/examples/native-workflows/tables-and-cells/flows/coordinates.flow +17 -0
- package/examples/native-workflows/tables-and-cells/flows/search-extract-reuse.flow +27 -0
- package/examples/native-workflows/tables-and-cells/negative/wrong-cell-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/README.md +140 -0
- package/examples/native-workflows/variables-and-reuse/flows/reuse.flow +9 -0
- package/examples/native-workflows/variables-and-reuse/flows/setup.first.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/negative/wrong-reused-value.flow +27 -0
- package/examples/native-workflows/variables-and-reuse/sessions/restore.flow +10 -0
- package/examples/native-workflows/variables-and-reuse/sessions/save.flow +29 -0
- package/package.json +23 -9
- package/src/ui/dist/assets/index-s0WYkGPt.js +636 -0
- package/src/ui/dist/index.html +1 -1
- 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
|
|
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
|
+
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
? { ...
|
|
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.
|
|
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(
|
|
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 (
|
|
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.
|
|
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,
|
|
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
|
|
281
|
-
//
|
|
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
|
|
469
|
+
void this.clearSessionStorageRestore(page).catch(() => {
|
|
470
|
+
console.warn('[Session]: Could not remove the completed storage initializer.');
|
|
288
471
|
});
|
|
289
472
|
};
|
|
290
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
1267
|
-
if (this.
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
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
|
+
};
|