flowstride 1.0.20-rc.6 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NATIVE_BROWSER.md +341 -0
- package/NATIVE_CI.md +88 -169
- package/README.md +46 -214
- package/dist/adapters/api/undici.d.ts +8 -0
- package/dist/adapters/api/undici.js +11 -0
- package/dist/adapters/web/playwright.d.ts +25 -2
- package/dist/adapters/web/playwright.js +285 -21
- 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 +141 -37
- package/dist/security/redaction.js +3 -1
- package/dist/sessions/manager.js +3 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/replayRequest.d.ts +8 -0
- package/dist/utils/replayRequest.js +77 -0
- package/package.json +22 -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-DVwp0K-J.js +0 -462
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FlowstrideConfig } from '../types';
|
|
2
|
+
import { WebAdapter } from '../adapters/web/playwright';
|
|
3
|
+
export declare class ArmedEvent<T> {
|
|
4
|
+
private detach;
|
|
5
|
+
private settled;
|
|
6
|
+
private timer;
|
|
7
|
+
private resolve;
|
|
8
|
+
readonly promise: Promise<{
|
|
9
|
+
value?: T;
|
|
10
|
+
error?: Error;
|
|
11
|
+
}>;
|
|
12
|
+
constructor(timeout: number, detach: () => void);
|
|
13
|
+
capture(value: T): void;
|
|
14
|
+
fail(error: Error): void;
|
|
15
|
+
take(): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
export declare function matchesURL(pattern: string, url: string): boolean;
|
|
18
|
+
export declare class NativeEvents {
|
|
19
|
+
private web;
|
|
20
|
+
private config;
|
|
21
|
+
private routes;
|
|
22
|
+
private events;
|
|
23
|
+
private captured;
|
|
24
|
+
private routeErrors;
|
|
25
|
+
private generation;
|
|
26
|
+
private downloads;
|
|
27
|
+
private savedDownloads;
|
|
28
|
+
constructor(web: WebAdapter, config: FlowstrideConfig);
|
|
29
|
+
private key;
|
|
30
|
+
network(action: string, input: unknown): Promise<any>;
|
|
31
|
+
download(action: string, input: unknown): Promise<any>;
|
|
32
|
+
disposeProfile(profile: string): Promise<void>;
|
|
33
|
+
dispose(): Promise<void>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
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.NativeEvents = exports.ArmedEvent = void 0;
|
|
7
|
+
exports.matchesURL = matchesURL;
|
|
8
|
+
const zod_1 = require("zod");
|
|
9
|
+
const validation_1 = require("./validation");
|
|
10
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const node_util_1 = require("node:util");
|
|
15
|
+
// The promise always resolves to an outcome, including timeout/cancellation. Arming a
|
|
16
|
+
// waiter never leaves a rejecting promise while a following click is still pending.
|
|
17
|
+
class ArmedEvent {
|
|
18
|
+
detach;
|
|
19
|
+
settled = false;
|
|
20
|
+
timer;
|
|
21
|
+
resolve;
|
|
22
|
+
promise = new Promise(resolve => { this.resolve = resolve; });
|
|
23
|
+
constructor(timeout, detach) {
|
|
24
|
+
this.detach = detach;
|
|
25
|
+
this.timer = setTimeout(() => this.fail(new Error('Armed event timed out.')), validation_1.positive.parse(timeout));
|
|
26
|
+
}
|
|
27
|
+
capture(value) { if (this.settled)
|
|
28
|
+
return; this.settled = true; clearTimeout(this.timer); this.detach(); this.resolve({ value }); }
|
|
29
|
+
fail(error) { if (this.settled)
|
|
30
|
+
return; this.settled = true; clearTimeout(this.timer); this.detach(); this.resolve({ error }); }
|
|
31
|
+
async take() { const result = await this.promise; if (result.error)
|
|
32
|
+
throw result.error; return result.value; }
|
|
33
|
+
}
|
|
34
|
+
exports.ArmedEvent = ArmedEvent;
|
|
35
|
+
const urlPattern = zod_1.z.string().min(1).max(4000);
|
|
36
|
+
function matchesURL(pattern, url) {
|
|
37
|
+
const regexp = pattern.split('**').map(part => part.split('*').map(bit => bit.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
|
|
38
|
+
return new RegExp(`^${regexp}$`).test(url);
|
|
39
|
+
}
|
|
40
|
+
const routeSchema = zod_1.z.object({ name: validation_1.name, url: urlPattern, method: zod_1.z.string().optional(),
|
|
41
|
+
mode: zod_1.z.enum(['fulfill', 'abort', 'continue', 'patch']).default('fulfill'), status: zod_1.z.number().int().min(100).max(599).optional(),
|
|
42
|
+
headers: validation_1.headers.optional(), json: zod_1.z.unknown().optional(), body: zod_1.z.string().optional(), fixture: zod_1.z.string().optional(),
|
|
43
|
+
contentType: zod_1.z.string().optional(), request: zod_1.z.object({ url: zod_1.z.string().url().optional(), method: zod_1.z.string().optional(), headers: validation_1.headers.optional(), body: zod_1.z.string().optional() }).strict().optional(),
|
|
44
|
+
times: validation_1.positive.optional(),
|
|
45
|
+
}).strict();
|
|
46
|
+
const armSchema = zod_1.z.object({ name: validation_1.name, url: urlPattern, method: zod_1.z.string().optional(), kind: zod_1.z.enum(['request', 'response']).default('response'), timeout: validation_1.positive.optional(), status: zod_1.z.number().int().optional() }).strict();
|
|
47
|
+
const networkExpect = zod_1.z.object({ name: validation_1.name, status: zod_1.z.number().int().optional(), method: zod_1.z.string().optional(), header: zod_1.z.string().optional(), equals: zod_1.z.unknown().optional(), path: zod_1.z.string().optional(), contains: zod_1.z.string().optional() }).strict();
|
|
48
|
+
class NativeEvents {
|
|
49
|
+
web;
|
|
50
|
+
config;
|
|
51
|
+
routes = new Map();
|
|
52
|
+
events = new Map();
|
|
53
|
+
captured = new Map();
|
|
54
|
+
routeErrors = [];
|
|
55
|
+
generation = 0;
|
|
56
|
+
downloads = new Map();
|
|
57
|
+
savedDownloads = new Map();
|
|
58
|
+
constructor(web, config) {
|
|
59
|
+
this.web = web;
|
|
60
|
+
this.config = config;
|
|
61
|
+
}
|
|
62
|
+
key(value) { return `${this.web.activeProfile}:${value}`; }
|
|
63
|
+
async network(action, input) {
|
|
64
|
+
if (this.routeErrors.length)
|
|
65
|
+
throw new Error(`Network route failed: ${this.routeErrors.shift()}`);
|
|
66
|
+
if (action === 'route') {
|
|
67
|
+
const data = routeSchema.parse(input), key = this.key(data.name), context = this.web.getContext();
|
|
68
|
+
if (this.routes.has(key))
|
|
69
|
+
throw new Error('Route name already exists; remove it before replacing it.');
|
|
70
|
+
const responseFields = ['status', 'headers', 'json', 'body', 'fixture', 'contentType'];
|
|
71
|
+
if (['abort', 'continue'].includes(data.mode) && responseFields.some(key => key in data) || data.mode !== 'continue' && data.request !== undefined)
|
|
72
|
+
throw new Error('Route options do not apply to the selected mode.');
|
|
73
|
+
if (data.mode === 'abort' && data.request)
|
|
74
|
+
throw new Error('Abort routes cannot modify requests.');
|
|
75
|
+
if (data.mode === 'patch' && data.json !== undefined && (!data.json || typeof data.json !== 'object' || Array.isArray(data.json)))
|
|
76
|
+
throw new Error('Response patches require a JSON object.');
|
|
77
|
+
if ([data.json !== undefined, data.body !== undefined, data.fixture !== undefined].filter(Boolean).length > 1)
|
|
78
|
+
throw new Error('Choose one route body source.');
|
|
79
|
+
const body = data.fixture ? (0, validation_1.readProjectFile)(this.config, data.fixture) : data.body;
|
|
80
|
+
let remaining = data.times ?? Infinity;
|
|
81
|
+
const generation = this.generation;
|
|
82
|
+
const operation = async (route) => {
|
|
83
|
+
if (remaining <= 0 || data.method && route.request().method().toUpperCase() !== data.method.toUpperCase())
|
|
84
|
+
return route.fallback();
|
|
85
|
+
if (--remaining === 0)
|
|
86
|
+
this.routes.delete(key);
|
|
87
|
+
if (data.mode === 'abort')
|
|
88
|
+
return route.abort();
|
|
89
|
+
if (data.mode === 'continue')
|
|
90
|
+
return route.continue({ ...data.request, postData: data.request?.body, headers: data.request?.headers ? { ...route.request().headers(), ...data.request.headers } : undefined });
|
|
91
|
+
if (data.mode === 'patch') {
|
|
92
|
+
const response = await route.fetch();
|
|
93
|
+
const original = data.json === undefined ? undefined : await response.json();
|
|
94
|
+
await route.fulfill({ response, status: data.status, headers: data.headers ? { ...response.headers(), ...data.headers } : undefined,
|
|
95
|
+
...(data.json === undefined ? { body } : { json: { ...original, ...data.json } }) });
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
await route.fulfill({ status: data.status ?? 200, headers: data.headers, contentType: data.contentType,
|
|
99
|
+
...(data.json === undefined ? { body: body ?? '' } : { json: data.json }) });
|
|
100
|
+
};
|
|
101
|
+
const handler = async (route) => {
|
|
102
|
+
try {
|
|
103
|
+
await operation(route);
|
|
104
|
+
if (remaining === 0)
|
|
105
|
+
await context.unroute(data.url, handler);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
109
|
+
if (generation === this.generation) {
|
|
110
|
+
this.routeErrors.push(message);
|
|
111
|
+
this.web.pageErrors.push(`Native route: ${message}`);
|
|
112
|
+
}
|
|
113
|
+
await route.abort().catch(() => { });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await context.route(data.url, handler);
|
|
117
|
+
this.routes.set(key, { context, pattern: data.url, handler });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (action === 'unroute') {
|
|
121
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), key = this.key(data.name), route = this.routes.get(key);
|
|
122
|
+
if (!route)
|
|
123
|
+
throw new Error('Unknown route.');
|
|
124
|
+
await route.context.unroute(route.pattern, route.handler);
|
|
125
|
+
this.routes.delete(key);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (action === 'arm') {
|
|
129
|
+
const data = armSchema.parse(input), key = this.key(data.name), context = this.web.getContext();
|
|
130
|
+
if (data.kind === 'request' && data.status !== undefined)
|
|
131
|
+
throw new Error('A request event has no response status.');
|
|
132
|
+
if (this.events.has(key))
|
|
133
|
+
throw new Error('Event name already armed.');
|
|
134
|
+
const listener = (event) => {
|
|
135
|
+
const request = data.kind === 'request' ? event : event.request();
|
|
136
|
+
if (!matchesURL(data.url, request.url()) || data.method && request.method().toUpperCase() !== data.method.toUpperCase() || data.kind === 'response' && data.status !== undefined && event.status() !== data.status)
|
|
137
|
+
return;
|
|
138
|
+
armed.capture(event);
|
|
139
|
+
};
|
|
140
|
+
const armed = new ArmedEvent(data.timeout ?? this.config.timeout, () => data.kind === 'request' ? context.off('request', listener) : context.off('response', listener));
|
|
141
|
+
if (data.kind === 'request')
|
|
142
|
+
context.on('request', listener);
|
|
143
|
+
else
|
|
144
|
+
context.on('response', listener);
|
|
145
|
+
this.events.set(key, armed);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (action === 'wait') {
|
|
149
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), key = this.key(data.name), armed = this.events.get(key);
|
|
150
|
+
if (!armed)
|
|
151
|
+
throw new Error('Arm the network event before triggering its request.');
|
|
152
|
+
try {
|
|
153
|
+
const event = await armed.take(), response = typeof event.status === 'function' ? event : undefined;
|
|
154
|
+
const request = response ? response.request() : event;
|
|
155
|
+
let body = request.postData();
|
|
156
|
+
if (response) {
|
|
157
|
+
const length = Number(response.headers()['content-length'] || 0);
|
|
158
|
+
if (length > 20 * 1024 * 1024)
|
|
159
|
+
throw new Error('Captured response exceeds the 20 MiB limit.');
|
|
160
|
+
const content = await response.body();
|
|
161
|
+
if (content.length > 20 * 1024 * 1024)
|
|
162
|
+
throw new Error('Captured response exceeds the 20 MiB limit.');
|
|
163
|
+
body = content.toString('utf8');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(String(body));
|
|
167
|
+
}
|
|
168
|
+
catch { }
|
|
169
|
+
const result = { url: request.url(), method: request.method(), status: response?.status(), headers: response ? await response.allHeaders() : await request.allHeaders(), body };
|
|
170
|
+
this.captured.set(key, result);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
this.events.delete(key);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (action === 'expect') {
|
|
178
|
+
const data = networkExpect.parse(input), result = this.captured.get(this.key(data.name));
|
|
179
|
+
if (!result)
|
|
180
|
+
throw new Error('No captured event; call network.wait first.');
|
|
181
|
+
if (!['status', 'method', 'equals', 'contains'].some(key => key in data))
|
|
182
|
+
throw new Error('Provide a network expectation.');
|
|
183
|
+
if (data.status !== undefined && result.status !== data.status || data.method && result.method !== data.method.toUpperCase())
|
|
184
|
+
throw new Error('Network status/method assertion failed.');
|
|
185
|
+
let actual = data.header ? result.headers[data.header.toLowerCase()] : result.body;
|
|
186
|
+
if (data.path)
|
|
187
|
+
for (const key of data.path.split('.'))
|
|
188
|
+
actual = actual?.[key];
|
|
189
|
+
if ('equals' in data && !(0, node_util_1.isDeepStrictEqual)(actual, data.equals) || data.contains !== undefined && !String(actual).includes(data.contains))
|
|
190
|
+
throw new Error('Network content assertion failed.');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Unknown network action: ${action}`);
|
|
194
|
+
}
|
|
195
|
+
async download(action, input) {
|
|
196
|
+
if (action === 'arm') {
|
|
197
|
+
const data = zod_1.z.object({ name: validation_1.name, timeout: validation_1.positive.optional() }).strict().parse(input), key = this.key(data.name), page = this.web.getPage();
|
|
198
|
+
if (this.downloads.has(key))
|
|
199
|
+
throw new Error('Download name already armed.');
|
|
200
|
+
const listener = (download) => armed.capture(download);
|
|
201
|
+
const armed = new ArmedEvent(data.timeout ?? this.config.timeout, () => page.off('download', listener));
|
|
202
|
+
page.on('download', listener);
|
|
203
|
+
this.downloads.set(key, armed);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (action === 'save') {
|
|
207
|
+
const data = zod_1.z.object({ name: validation_1.name, file: zod_1.z.string().min(1) }).strict().parse(input), key = this.key(data.name), armed = this.downloads.get(key);
|
|
208
|
+
if (!armed)
|
|
209
|
+
throw new Error('Arm the download before clicking its trigger.');
|
|
210
|
+
try {
|
|
211
|
+
const download = await armed.take();
|
|
212
|
+
const file = (0, projectPaths_1.projectPath)(this.config.projectRoot || process.cwd(), data.file);
|
|
213
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
214
|
+
if (node_fs_1.default.existsSync(file))
|
|
215
|
+
throw new Error('Download destination exists; choose a new file.');
|
|
216
|
+
await download.saveAs(file);
|
|
217
|
+
const failure = await download.failure();
|
|
218
|
+
if (failure)
|
|
219
|
+
throw new Error(`Download failed: ${failure}`);
|
|
220
|
+
this.savedDownloads.set(key, { file, suggested: download.suggestedFilename() });
|
|
221
|
+
return file;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
this.downloads.delete(key);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (action === 'expect') {
|
|
228
|
+
const data = zod_1.z.object({ name: validation_1.name, filename: zod_1.z.string().optional(), size: zod_1.z.number().int().nonnegative().optional(), minSize: zod_1.z.number().int().nonnegative().optional(), contains: zod_1.z.string().optional(), sha256: zod_1.z.string().regex(/^[a-f0-9]{64}$/).optional(), json: zod_1.z.unknown().optional() }).strict().parse(input);
|
|
229
|
+
if (!['filename', 'size', 'minSize', 'contains', 'sha256', 'json'].some(key => key in data))
|
|
230
|
+
throw new Error('Provide a download expectation.');
|
|
231
|
+
const saved = this.savedDownloads.get(this.key(data.name));
|
|
232
|
+
if (!saved)
|
|
233
|
+
throw new Error('Download not saved.');
|
|
234
|
+
const absolute = (0, projectPaths_1.projectPath)(this.config.projectRoot || process.cwd(), saved.file), length = node_fs_1.default.statSync(absolute).size;
|
|
235
|
+
const body = data.contains !== undefined || 'json' in data ? (0, validation_1.readProjectFile)(this.config, saved.file) : Buffer.alloc(0);
|
|
236
|
+
let sha256;
|
|
237
|
+
if (data.sha256) {
|
|
238
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
239
|
+
for await (const chunk of node_fs_1.default.createReadStream(absolute))
|
|
240
|
+
hash.update(chunk);
|
|
241
|
+
sha256 = hash.digest('hex');
|
|
242
|
+
}
|
|
243
|
+
if (data.filename !== undefined && saved.suggested !== data.filename || data.size !== undefined && length !== data.size || data.minSize !== undefined && length < data.minSize || data.contains !== undefined && !body.toString('utf8').includes(data.contains) || data.sha256 !== undefined && sha256 !== data.sha256 || 'json' in data && !(0, node_util_1.isDeepStrictEqual)(JSON.parse(body.toString('utf8')), data.json))
|
|
244
|
+
throw new Error('Download assertion failed.');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`Unknown download action: ${action}`);
|
|
248
|
+
}
|
|
249
|
+
async disposeProfile(profile) {
|
|
250
|
+
const prefix = profile + ':';
|
|
251
|
+
for (const map of [this.events, this.downloads])
|
|
252
|
+
for (const [key, event] of map)
|
|
253
|
+
if (key.startsWith(prefix)) {
|
|
254
|
+
event.fail(new Error('Profile closed.'));
|
|
255
|
+
map.delete(key);
|
|
256
|
+
}
|
|
257
|
+
for (const map of [this.captured, this.savedDownloads])
|
|
258
|
+
for (const key of map.keys())
|
|
259
|
+
if (key.startsWith(prefix))
|
|
260
|
+
map.delete(key);
|
|
261
|
+
for (const [key, route] of this.routes)
|
|
262
|
+
if (key.startsWith(prefix)) {
|
|
263
|
+
await route.context.unroute(route.pattern, route.handler).catch(() => { });
|
|
264
|
+
this.routes.delete(key);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async dispose() {
|
|
268
|
+
this.generation++;
|
|
269
|
+
for (const armed of [...this.events.values(), ...this.downloads.values()])
|
|
270
|
+
armed.fail(new Error('Event cancelled during cleanup.'));
|
|
271
|
+
this.events.clear();
|
|
272
|
+
this.downloads.clear();
|
|
273
|
+
this.captured.clear();
|
|
274
|
+
this.savedDownloads.clear();
|
|
275
|
+
for (const route of this.routes.values())
|
|
276
|
+
await route.context.unroute(route.pattern, route.handler).catch(() => { });
|
|
277
|
+
this.routes.clear();
|
|
278
|
+
this.routeErrors = [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
exports.NativeEvents = NativeEvents;
|
|
@@ -0,0 +1,31 @@
|
|
|
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.serveDOMSnapshot = serveDOMSnapshot;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
10
|
+
function serveDOMSnapshot(req, res, config) {
|
|
11
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
12
|
+
if (url.pathname !== '/evidence')
|
|
13
|
+
return false;
|
|
14
|
+
try {
|
|
15
|
+
if (req.method !== 'GET' || !config?.domSnapshots)
|
|
16
|
+
throw new Error('Unavailable');
|
|
17
|
+
const root = config.projectRoot || process.cwd();
|
|
18
|
+
const file = (0, projectPaths_1.projectPath)(root, url.searchParams.get('file') || '');
|
|
19
|
+
const directory = (0, projectPaths_1.projectPath)(root, config.artifactsDir || 'flowstride-artifacts');
|
|
20
|
+
const relative = node_path_1.default.relative(directory, file);
|
|
21
|
+
if (!relative || relative.startsWith('..') || node_path_1.default.isAbsolute(relative) || !file.endsWith('.dom.html') || !node_fs_1.default.statSync(file).isFile() || node_fs_1.default.statSync(file).size > 10 * 1024 * 1024)
|
|
22
|
+
throw new Error('Unavailable');
|
|
23
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cache-Control': 'no-store' });
|
|
24
|
+
node_fs_1.default.createReadStream(file).pipe(res);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
res.writeHead(404);
|
|
28
|
+
res.end('Snapshot unavailable.');
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
export declare class NativeFunctions {
|
|
3
|
+
private installed;
|
|
4
|
+
private pages;
|
|
5
|
+
private counter;
|
|
6
|
+
private key;
|
|
7
|
+
execute(page: Page, action: string, input: unknown, timeout: number): Promise<void>;
|
|
8
|
+
dispose(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NativeFunctions = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const validation_1 = require("./validation");
|
|
6
|
+
const node_util_1 = require("node:util");
|
|
7
|
+
const target = zod_1.z.string().min(1).refine(value => value.split('.').every(key => /^[a-zA-Z_$][\w$]*$/.test(key) && !['__proto__', 'prototype', 'constructor'].includes(key)), 'Use a safe dotted function path.');
|
|
8
|
+
const schema = zod_1.z.object({ name: validation_1.name, target, mode: zod_1.z.enum(['spy', 'return', 'throw']).default('spy'), value: zod_1.z.unknown().optional(), error: zod_1.z.string().optional(), async: zod_1.z.boolean().default(false), beforeLoad: zod_1.z.boolean().default(false) }).strict();
|
|
9
|
+
function install(data) {
|
|
10
|
+
const symbol = Symbol.for('flowstride.function-doubles');
|
|
11
|
+
const registry = window[symbol] ||= Object.create(null);
|
|
12
|
+
if (registry[data.name])
|
|
13
|
+
throw new Error('Function double name already exists.');
|
|
14
|
+
const keys = data.target.replace(/^window\./, '').split('.');
|
|
15
|
+
let object = window;
|
|
16
|
+
for (const key of keys.slice(0, -1))
|
|
17
|
+
object = object[key];
|
|
18
|
+
const key = keys.at(-1), original = object[key], own = Object.getOwnPropertyDescriptor(object, key);
|
|
19
|
+
if (typeof original !== 'function')
|
|
20
|
+
throw new Error(`Target is not a function: ${data.target}`);
|
|
21
|
+
const calls = [];
|
|
22
|
+
const replacement = function (...args) {
|
|
23
|
+
let snapshot;
|
|
24
|
+
try {
|
|
25
|
+
snapshot = JSON.parse(JSON.stringify(args));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
snapshot = args.map(value => String(value));
|
|
29
|
+
}
|
|
30
|
+
calls.push(snapshot);
|
|
31
|
+
if (data.mode === 'spy')
|
|
32
|
+
return original.apply(this, args);
|
|
33
|
+
if (data.mode === 'throw') {
|
|
34
|
+
const error = new Error(data.error || 'Stubbed function error');
|
|
35
|
+
if (data.async)
|
|
36
|
+
return Promise.reject(error);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return data.async ? Promise.resolve(data.value) : data.value;
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(object, key, { configurable: true, writable: true, value: replacement });
|
|
42
|
+
registry[data.name] = { calls, restore: () => { if (own)
|
|
43
|
+
Object.defineProperty(object, key, own);
|
|
44
|
+
else
|
|
45
|
+
delete object[key]; } };
|
|
46
|
+
}
|
|
47
|
+
class NativeFunctions {
|
|
48
|
+
installed = new Map();
|
|
49
|
+
pages = new WeakMap();
|
|
50
|
+
counter = 0;
|
|
51
|
+
key(page, name) { if (!this.pages.has(page))
|
|
52
|
+
this.pages.set(page, ++this.counter); return `${this.pages.get(page)}:${name}`; }
|
|
53
|
+
async execute(page, action, input, timeout) {
|
|
54
|
+
if (action === 'install') {
|
|
55
|
+
const data = schema.parse(input);
|
|
56
|
+
if (this.installed.has(this.key(page, data.name)))
|
|
57
|
+
throw new Error('Function double name already exists; restore it first.');
|
|
58
|
+
const script = data.beforeLoad ? await page.addInitScript(install, data) : undefined;
|
|
59
|
+
try {
|
|
60
|
+
if (!data.beforeLoad || page.url() !== 'about:blank')
|
|
61
|
+
await page.evaluate(install, data);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
await script?.dispose();
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
this.installed.set(this.key(page, data.name), { name: data.name, page, script });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (action === 'restore') {
|
|
71
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), entry = this.installed.get(this.key(page, data.name));
|
|
72
|
+
if (!entry || entry.page !== page)
|
|
73
|
+
throw new Error('Unknown function double on this page.');
|
|
74
|
+
await entry.script?.dispose();
|
|
75
|
+
await page.evaluate(name => { const registry = window[Symbol.for('flowstride.function-doubles')]; registry?.[name]?.restore(); if (registry)
|
|
76
|
+
delete registry[name]; }, data.name);
|
|
77
|
+
this.installed.delete(this.key(page, data.name));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'expect') {
|
|
81
|
+
const data = zod_1.z.object({ name: validation_1.name, count: zod_1.z.number().int().nonnegative().optional(), minCount: zod_1.z.number().int().nonnegative().optional(), args: zod_1.z.array(zod_1.z.unknown()).optional(), timeout: validation_1.positive.optional() }).strict().parse(input);
|
|
82
|
+
if (!['count', 'minCount', 'args'].some(key => key in data))
|
|
83
|
+
throw new Error('Provide a call count or expected arguments.');
|
|
84
|
+
await (0, validation_1.poll)(async () => {
|
|
85
|
+
const calls = await page.evaluate(name => window[Symbol.for('flowstride.function-doubles')]?.[name]?.calls, data.name);
|
|
86
|
+
return Array.isArray(calls) && (data.count === undefined || calls.length === data.count) && (data.minCount === undefined || calls.length >= data.minCount) && (data.args === undefined || calls.some(call => (0, node_util_1.isDeepStrictEqual)(call, data.args)));
|
|
87
|
+
}, data.timeout ?? timeout, `function double ${data.name}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Unknown stub action: ${action}`);
|
|
91
|
+
}
|
|
92
|
+
async dispose() {
|
|
93
|
+
for (const entry of this.installed.values()) {
|
|
94
|
+
const name = entry.name;
|
|
95
|
+
await entry.script?.dispose().catch(() => { });
|
|
96
|
+
if (!entry.page.isClosed())
|
|
97
|
+
await entry.page.evaluate(name => { const registry = window[Symbol.for('flowstride.function-doubles')]; registry?.[name]?.restore(); if (registry)
|
|
98
|
+
delete registry[name]; }, name).catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
this.installed.clear();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
exports.NativeFunctions = NativeFunctions;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FlowstrideConfig } from '../types';
|
|
2
|
+
import { WebAdapter } from '../adapters/web/playwright';
|
|
3
|
+
import { NativeEvents } from './events';
|
|
4
|
+
export declare class NativeRuntime {
|
|
5
|
+
private web;
|
|
6
|
+
private config;
|
|
7
|
+
readonly events: NativeEvents;
|
|
8
|
+
private functions;
|
|
9
|
+
private components;
|
|
10
|
+
constructor(web: WebAdapter, config: FlowstrideConfig);
|
|
11
|
+
execute(command: string, input?: unknown): Promise<unknown>;
|
|
12
|
+
captureDOM(step: string): Promise<string | undefined>;
|
|
13
|
+
dispose(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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.NativeRuntime = void 0;
|
|
7
|
+
const zod_1 = require("zod");
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
const validation_1 = require("./validation");
|
|
11
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
12
|
+
const events_1 = require("./events");
|
|
13
|
+
const functions_1 = require("./functions");
|
|
14
|
+
const components_1 = require("./components");
|
|
15
|
+
const assertions_1 = require("./assertions");
|
|
16
|
+
const accessibility_1 = require("./accessibility");
|
|
17
|
+
class NativeRuntime {
|
|
18
|
+
web;
|
|
19
|
+
config;
|
|
20
|
+
events;
|
|
21
|
+
functions = new functions_1.NativeFunctions();
|
|
22
|
+
components;
|
|
23
|
+
constructor(web, config) {
|
|
24
|
+
this.web = web;
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.events = new events_1.NativeEvents(web, config);
|
|
27
|
+
this.components = new components_1.NativeComponents(config);
|
|
28
|
+
}
|
|
29
|
+
async execute(command, input = {}) {
|
|
30
|
+
const [area, action, extra] = command.split('.');
|
|
31
|
+
if (!action || extra)
|
|
32
|
+
throw new Error(`Unknown native command: flow.${command}`);
|
|
33
|
+
if (area === 'browser') {
|
|
34
|
+
if (action === 'create') {
|
|
35
|
+
const data = zod_1.z.object({ name: validation_1.name, options: zod_1.z.unknown().optional() }).strict().parse(input);
|
|
36
|
+
(0, validation_1.validateContextOptions)(data.options, this.config.browser, this.config.projectRoot);
|
|
37
|
+
await this.web.createProfile(data.name, data.options);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action === 'use' || action === 'close') {
|
|
41
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input);
|
|
42
|
+
if (action === 'use')
|
|
43
|
+
await this.web.useProfile(data.name);
|
|
44
|
+
else {
|
|
45
|
+
if (data.name === 'default')
|
|
46
|
+
throw new Error('The default profile stays available until file completion.');
|
|
47
|
+
await this.events.disposeProfile(data.name);
|
|
48
|
+
await this.web.closeProfile(data.name);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (action === 'list') {
|
|
53
|
+
zod_1.z.object({}).strict().parse(input);
|
|
54
|
+
return this.web.profileNames();
|
|
55
|
+
}
|
|
56
|
+
if (action === 'offline') {
|
|
57
|
+
const data = zod_1.z.object({ value: zod_1.z.boolean() }).strict().parse(input);
|
|
58
|
+
await this.web.getContext().setOffline(data.value);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (action === 'permissions') {
|
|
62
|
+
const data = zod_1.z.object({ permissions: zod_1.z.array(zod_1.z.string()), origin: zod_1.z.string().url().optional() }).strict().parse(input);
|
|
63
|
+
await this.web.getContext().grantPermissions(data.permissions, { origin: data.origin });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (action === 'clearPermissions') {
|
|
67
|
+
zod_1.z.object({}).strict().parse(input);
|
|
68
|
+
await this.web.getContext().clearPermissions();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (action === 'geolocation') {
|
|
72
|
+
const data = zod_1.z.object({ latitude: zod_1.z.number().min(-90).max(90), longitude: zod_1.z.number().min(-180).max(180), accuracy: zod_1.z.number().nonnegative().optional() }).strict().parse(input);
|
|
73
|
+
await this.web.getContext().setGeolocation(data);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Unknown browser action: ${action}`);
|
|
77
|
+
}
|
|
78
|
+
if (area === 'assert') {
|
|
79
|
+
if (action !== 'that')
|
|
80
|
+
throw new Error(`Unknown assertion action: ${action}`);
|
|
81
|
+
return (0, assertions_1.assertBrowser)(this.web.getPage(), input, this.config.timeout);
|
|
82
|
+
}
|
|
83
|
+
if (area === 'network')
|
|
84
|
+
return this.events.network(action, input);
|
|
85
|
+
if (area === 'download')
|
|
86
|
+
return this.events.download(action, input);
|
|
87
|
+
if (area === 'stub')
|
|
88
|
+
return this.functions.execute(this.web.getPage(), action, input, this.config.timeout);
|
|
89
|
+
if (area === 'component')
|
|
90
|
+
return this.components.execute(this.web.getPage(), action, input);
|
|
91
|
+
if (area === 'a11y')
|
|
92
|
+
return (0, accessibility_1.accessibility)(this.web.getPage(), this.config, action, input);
|
|
93
|
+
if (area === 'clock') {
|
|
94
|
+
const clock = this.web.getPage().clock;
|
|
95
|
+
if (action === 'install') {
|
|
96
|
+
const data = zod_1.z.object({ time: zod_1.z.union([zod_1.z.string(), zod_1.z.number()]).optional() }).strict().parse(input);
|
|
97
|
+
await clock.install(data);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (action === 'resume') {
|
|
101
|
+
zod_1.z.object({}).strict().parse(input);
|
|
102
|
+
await clock.resume();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (['pauseAt', 'setFixedTime', 'setSystemTime'].includes(action)) {
|
|
106
|
+
const data = zod_1.z.object({ time: zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number().finite()]) }).strict().parse(input);
|
|
107
|
+
if (typeof data.time === 'string' && !Number.isFinite(Date.parse(data.time)))
|
|
108
|
+
throw new Error('Clock time must be a valid date.');
|
|
109
|
+
await clock[action](data.time);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (['runFor', 'fastForward'].includes(action)) {
|
|
113
|
+
const data = zod_1.z.object({ milliseconds: zod_1.z.number().int().min(0).max(86400000) }).strict().parse(input);
|
|
114
|
+
await clock[action](data.milliseconds);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Unknown clock action: ${action}`);
|
|
118
|
+
}
|
|
119
|
+
if (area === 'trace') {
|
|
120
|
+
if (action === 'start') {
|
|
121
|
+
const data = zod_1.z.object({ name: validation_1.name.optional() }).strict().parse(input);
|
|
122
|
+
await this.web.beginTrace(undefined, data.name ? `${data.name}-trace.zip` : undefined);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (action === 'stop') {
|
|
126
|
+
zod_1.z.object({}).strict().parse(input);
|
|
127
|
+
return this.web.endTrace();
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Unknown trace action: ${action}`);
|
|
130
|
+
}
|
|
131
|
+
if (area === 'har') {
|
|
132
|
+
if (action === 'replay') {
|
|
133
|
+
const data = zod_1.z.object({ file: zod_1.z.string().min(1), url: zod_1.z.string().optional(), notFound: zod_1.z.enum(['abort', 'fallback']).default('abort') }).strict().parse(input);
|
|
134
|
+
(0, validation_1.readProjectFile)(this.config, data.file, 100 * 1024 * 1024);
|
|
135
|
+
await this.web.getContext().routeFromHAR((0, projectPaths_1.projectPath)(this.config.projectRoot || process.cwd(), data.file), { url: data.url, notFound: data.notFound, update: false });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Unknown HAR action: ${action}. Enable har in configuration before browser creation to record.`);
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Unknown native command: flow.${command}`);
|
|
141
|
+
}
|
|
142
|
+
async captureDOM(step) {
|
|
143
|
+
if (!this.config.domSnapshots)
|
|
144
|
+
return;
|
|
145
|
+
const page = this.web.getPage();
|
|
146
|
+
// Saved HTML is diagnostic data. It is never injected into an executable frame.
|
|
147
|
+
const html = await page.content();
|
|
148
|
+
const file = (0, validation_1.evidencePath)(this.config, `${validation_1.name.parse(step)}-${(0, node_crypto_1.randomUUID)()}.dom.html`);
|
|
149
|
+
node_fs_1.default.writeFileSync(file, html, { mode: 0o600 });
|
|
150
|
+
return file;
|
|
151
|
+
}
|
|
152
|
+
async dispose() { await this.events.dispose(); await this.functions.dispose(); await this.components.dispose(); }
|
|
153
|
+
}
|
|
154
|
+
exports.NativeRuntime = NativeRuntime;
|