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
|
@@ -0,0 +1,308 @@
|
|
|
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 assertion_result_1 = require("../reporting/assertion_result");
|
|
9
|
+
const zod_1 = require("zod");
|
|
10
|
+
const validation_1 = require("./validation");
|
|
11
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const node_util_1 = require("node:util");
|
|
16
|
+
// The promise always resolves to an outcome, including timeout/cancellation. Arming a
|
|
17
|
+
// waiter never leaves a rejecting promise while a following click is still pending.
|
|
18
|
+
class ArmedEvent {
|
|
19
|
+
detach;
|
|
20
|
+
settled = false;
|
|
21
|
+
timer;
|
|
22
|
+
resolve;
|
|
23
|
+
promise = new Promise(resolve => { this.resolve = resolve; });
|
|
24
|
+
constructor(timeout, detach) {
|
|
25
|
+
this.detach = detach;
|
|
26
|
+
this.timer = setTimeout(() => this.fail(new Error('Armed event timed out.')), validation_1.positive.parse(timeout));
|
|
27
|
+
}
|
|
28
|
+
capture(value) { if (this.settled)
|
|
29
|
+
return; this.settled = true; clearTimeout(this.timer); this.detach(); this.resolve({ value }); }
|
|
30
|
+
fail(error) { if (this.settled)
|
|
31
|
+
return; this.settled = true; clearTimeout(this.timer); this.detach(); this.resolve({ error }); }
|
|
32
|
+
async take() { const result = await this.promise; if (result.error)
|
|
33
|
+
throw result.error; return result.value; }
|
|
34
|
+
}
|
|
35
|
+
exports.ArmedEvent = ArmedEvent;
|
|
36
|
+
const urlPattern = zod_1.z.string().min(1).max(4000);
|
|
37
|
+
function matchesURL(pattern, url) {
|
|
38
|
+
const regexp = pattern.split('**').map(part => part.split('*').map(bit => bit.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
|
|
39
|
+
return new RegExp(`^${regexp}$`).test(url);
|
|
40
|
+
}
|
|
41
|
+
const routeSchema = zod_1.z.object({ name: validation_1.name, url: urlPattern, method: zod_1.z.string().optional(),
|
|
42
|
+
mode: zod_1.z.enum(['fulfill', 'abort', 'continue', 'patch']).default('fulfill'), status: zod_1.z.number().int().min(100).max(599).optional(),
|
|
43
|
+
headers: validation_1.headers.optional(), json: zod_1.z.unknown().optional(), body: zod_1.z.string().optional(), fixture: zod_1.z.string().optional(),
|
|
44
|
+
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(),
|
|
45
|
+
times: validation_1.positive.optional(),
|
|
46
|
+
}).strict();
|
|
47
|
+
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();
|
|
48
|
+
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();
|
|
49
|
+
class NativeEvents {
|
|
50
|
+
web;
|
|
51
|
+
config;
|
|
52
|
+
routes = new Map();
|
|
53
|
+
events = new Map();
|
|
54
|
+
captured = new Map();
|
|
55
|
+
routeErrors = [];
|
|
56
|
+
generation = 0;
|
|
57
|
+
downloads = new Map();
|
|
58
|
+
savedDownloads = new Map();
|
|
59
|
+
constructor(web, config) {
|
|
60
|
+
this.web = web;
|
|
61
|
+
this.config = config;
|
|
62
|
+
}
|
|
63
|
+
key(value) { return `${this.web.activeProfile}:${value}`; }
|
|
64
|
+
async network(action, input, reporter) {
|
|
65
|
+
if (this.routeErrors.length)
|
|
66
|
+
throw new Error(`Network route failed: ${this.routeErrors.shift()}`);
|
|
67
|
+
if (action === 'route') {
|
|
68
|
+
const data = routeSchema.parse(input), key = this.key(data.name), context = this.web.getContext();
|
|
69
|
+
if (this.routes.has(key))
|
|
70
|
+
throw new Error('Route name already exists; remove it before replacing it.');
|
|
71
|
+
const responseFields = ['status', 'headers', 'json', 'body', 'fixture', 'contentType'];
|
|
72
|
+
if (['abort', 'continue'].includes(data.mode) && responseFields.some(key => key in data) || data.mode !== 'continue' && data.request !== undefined)
|
|
73
|
+
throw new Error('Route options do not apply to the selected mode.');
|
|
74
|
+
if (data.mode === 'abort' && data.request)
|
|
75
|
+
throw new Error('Abort routes cannot modify requests.');
|
|
76
|
+
if (data.mode === 'patch' && data.json !== undefined && (!data.json || typeof data.json !== 'object' || Array.isArray(data.json)))
|
|
77
|
+
throw new Error('Response patches require a JSON object.');
|
|
78
|
+
if ([data.json !== undefined, data.body !== undefined, data.fixture !== undefined].filter(Boolean).length > 1)
|
|
79
|
+
throw new Error('Choose one route body source.');
|
|
80
|
+
const body = data.fixture ? (0, validation_1.readProjectFile)(this.config, data.fixture) : data.body;
|
|
81
|
+
let remaining = data.times ?? Infinity;
|
|
82
|
+
const generation = this.generation;
|
|
83
|
+
const operation = async (route) => {
|
|
84
|
+
if (remaining <= 0 || data.method && route.request().method().toUpperCase() !== data.method.toUpperCase())
|
|
85
|
+
return route.fallback();
|
|
86
|
+
if (--remaining === 0)
|
|
87
|
+
this.routes.delete(key);
|
|
88
|
+
if (data.mode === 'abort')
|
|
89
|
+
return route.abort();
|
|
90
|
+
if (data.mode === 'continue')
|
|
91
|
+
return route.continue({ ...data.request, postData: data.request?.body, headers: data.request?.headers ? { ...route.request().headers(), ...data.request.headers } : undefined });
|
|
92
|
+
if (data.mode === 'patch') {
|
|
93
|
+
const response = await route.fetch();
|
|
94
|
+
const original = data.json === undefined ? undefined : await response.json();
|
|
95
|
+
await route.fulfill({ response, status: data.status, headers: data.headers ? { ...response.headers(), ...data.headers } : undefined,
|
|
96
|
+
...(data.json === undefined ? { body } : { json: { ...original, ...data.json } }) });
|
|
97
|
+
}
|
|
98
|
+
else
|
|
99
|
+
await route.fulfill({ status: data.status ?? 200, headers: data.headers, contentType: data.contentType,
|
|
100
|
+
...(data.json === undefined ? { body: body ?? '' } : { json: data.json }) });
|
|
101
|
+
};
|
|
102
|
+
const handler = async (route) => {
|
|
103
|
+
try {
|
|
104
|
+
await operation(route);
|
|
105
|
+
if (remaining === 0)
|
|
106
|
+
await context.unroute(data.url, handler);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
if (generation === this.generation) {
|
|
111
|
+
this.routeErrors.push(message);
|
|
112
|
+
this.web.pageErrors.push(`Native route: ${message}`);
|
|
113
|
+
}
|
|
114
|
+
await route.abort().catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
await context.route(data.url, handler);
|
|
118
|
+
this.routes.set(key, { context, pattern: data.url, handler });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (action === 'unroute') {
|
|
122
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), key = this.key(data.name), route = this.routes.get(key);
|
|
123
|
+
if (!route)
|
|
124
|
+
throw new Error('Unknown route.');
|
|
125
|
+
await route.context.unroute(route.pattern, route.handler);
|
|
126
|
+
this.routes.delete(key);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (action === 'arm') {
|
|
130
|
+
const data = armSchema.parse(input), key = this.key(data.name), context = this.web.getContext();
|
|
131
|
+
if (data.kind === 'request' && data.status !== undefined)
|
|
132
|
+
throw new Error('A request event has no response status.');
|
|
133
|
+
if (this.events.has(key))
|
|
134
|
+
throw new Error('Event name already armed.');
|
|
135
|
+
const listener = (event) => {
|
|
136
|
+
const request = data.kind === 'request' ? event : event.request();
|
|
137
|
+
if (!matchesURL(data.url, request.url()) || data.method && request.method().toUpperCase() !== data.method.toUpperCase() || data.kind === 'response' && data.status !== undefined && event.status() !== data.status)
|
|
138
|
+
return;
|
|
139
|
+
armed.capture(event);
|
|
140
|
+
};
|
|
141
|
+
const armed = new ArmedEvent(data.timeout ?? this.config.timeout, () => data.kind === 'request' ? context.off('request', listener) : context.off('response', listener));
|
|
142
|
+
if (data.kind === 'request')
|
|
143
|
+
context.on('request', listener);
|
|
144
|
+
else
|
|
145
|
+
context.on('response', listener);
|
|
146
|
+
this.events.set(key, armed);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (action === 'wait') {
|
|
150
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), key = this.key(data.name), armed = this.events.get(key);
|
|
151
|
+
if (!armed)
|
|
152
|
+
throw new Error('Arm the network event before triggering its request.');
|
|
153
|
+
try {
|
|
154
|
+
const event = await armed.take(), response = typeof event.status === 'function' ? event : undefined;
|
|
155
|
+
const request = response ? response.request() : event;
|
|
156
|
+
let body = request.postData();
|
|
157
|
+
if (response) {
|
|
158
|
+
const length = Number(response.headers()['content-length'] || 0);
|
|
159
|
+
if (length > 20 * 1024 * 1024)
|
|
160
|
+
throw new Error('Captured response exceeds the 20 MiB limit.');
|
|
161
|
+
const content = await response.body();
|
|
162
|
+
if (content.length > 20 * 1024 * 1024)
|
|
163
|
+
throw new Error('Captured response exceeds the 20 MiB limit.');
|
|
164
|
+
body = content.toString('utf8');
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
body = JSON.parse(String(body));
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
const result = { url: request.url(), method: request.method(), status: response?.status(), headers: response ? await response.allHeaders() : await request.allHeaders(), body };
|
|
171
|
+
this.captured.set(key, result);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.events.delete(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (action === 'expect') {
|
|
179
|
+
const data = networkExpect.parse(input), result = this.captured.get(this.key(data.name));
|
|
180
|
+
if (!result)
|
|
181
|
+
throw new Error('No captured event; call network.wait first.');
|
|
182
|
+
if (!['status', 'method', 'equals', 'contains'].some(key => key in data))
|
|
183
|
+
throw new Error('Provide a network expectation.');
|
|
184
|
+
const checks = [];
|
|
185
|
+
if (data.status !== undefined)
|
|
186
|
+
checks.push((0, assertion_result_1.assertionCheck)('status', 'status', result.status === data.status, data.status, result.status));
|
|
187
|
+
if (data.method)
|
|
188
|
+
checks.push((0, assertion_result_1.assertionCheck)('method', 'method', result.method === data.method.toUpperCase(), data.method.toUpperCase(), result.method));
|
|
189
|
+
let actual = data.header ? result.headers[data.header.toLowerCase()] : result.body;
|
|
190
|
+
if (data.path)
|
|
191
|
+
for (const key of data.path.split('.'))
|
|
192
|
+
actual = actual?.[key];
|
|
193
|
+
const protect = (value) => /password|passcode|secret|token|authorization|cookie|api.?key|otp/i.test((data.header || '') + ' ' + (data.path || '')) ? '[REDACTED]' : value;
|
|
194
|
+
if ('equals' in data)
|
|
195
|
+
checks.push((0, assertion_result_1.assertionCheck)('equals', (data.header ? 'header ' + data.header : data.path ? 'body path ' + data.path : 'body') + ' equals', (0, node_util_1.isDeepStrictEqual)(actual, data.equals), protect(data.equals), protect(actual)));
|
|
196
|
+
if (data.contains !== undefined)
|
|
197
|
+
checks.push((0, assertion_result_1.assertionCheck)('contains', 'content contains', String(actual).includes(data.contains), protect(data.contains), protect(actual)));
|
|
198
|
+
(0, assertion_result_1.requireAssertion)('network.expect', 'captured network event ' + JSON.stringify(data.name), checks, reporter, { reason: checks.some(check => check.status === 'failed' && ['status', 'method'].includes(check.key)) ? 'Network status/method assertion failed.' : checks.some(check => check.status === 'failed') ? 'Network content assertion failed.' : undefined });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
throw new Error(`Unknown network action: ${action}`);
|
|
202
|
+
}
|
|
203
|
+
async download(action, input, reporter) {
|
|
204
|
+
if (action === 'arm') {
|
|
205
|
+
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();
|
|
206
|
+
if (this.downloads.has(key))
|
|
207
|
+
throw new Error('Download name already armed.');
|
|
208
|
+
const listener = (download) => armed.capture(download);
|
|
209
|
+
const armed = new ArmedEvent(data.timeout ?? this.config.timeout, () => page.off('download', listener));
|
|
210
|
+
page.on('download', listener);
|
|
211
|
+
this.downloads.set(key, armed);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (action === 'save') {
|
|
215
|
+
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);
|
|
216
|
+
if (!armed)
|
|
217
|
+
throw new Error('Arm the download before clicking its trigger.');
|
|
218
|
+
try {
|
|
219
|
+
const download = await armed.take();
|
|
220
|
+
const file = (0, projectPaths_1.projectPath)(this.config.projectRoot || process.cwd(), data.file);
|
|
221
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
222
|
+
if (node_fs_1.default.existsSync(file))
|
|
223
|
+
throw new Error('Download destination exists; choose a new file.');
|
|
224
|
+
await download.saveAs(file);
|
|
225
|
+
const failure = await download.failure();
|
|
226
|
+
if (failure)
|
|
227
|
+
throw new Error(`Download failed: ${failure}`);
|
|
228
|
+
this.savedDownloads.set(key, { file, suggested: download.suggestedFilename() });
|
|
229
|
+
return file;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
this.downloads.delete(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (action === 'expect') {
|
|
236
|
+
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);
|
|
237
|
+
if (!['filename', 'size', 'minSize', 'contains', 'sha256', 'json'].some(key => key in data))
|
|
238
|
+
throw new Error('Provide a download expectation.');
|
|
239
|
+
const saved = this.savedDownloads.get(this.key(data.name));
|
|
240
|
+
if (!saved)
|
|
241
|
+
throw new Error('Download not saved.');
|
|
242
|
+
const absolute = (0, projectPaths_1.projectPath)(this.config.projectRoot || process.cwd(), saved.file), length = node_fs_1.default.statSync(absolute).size;
|
|
243
|
+
const body = data.contains !== undefined || 'json' in data ? (0, validation_1.readProjectFile)(this.config, saved.file) : Buffer.alloc(0);
|
|
244
|
+
let sha256;
|
|
245
|
+
if (data.sha256) {
|
|
246
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
247
|
+
for await (const chunk of node_fs_1.default.createReadStream(absolute))
|
|
248
|
+
hash.update(chunk);
|
|
249
|
+
sha256 = hash.digest('hex');
|
|
250
|
+
}
|
|
251
|
+
const checks = [];
|
|
252
|
+
if (data.filename !== undefined)
|
|
253
|
+
checks.push((0, assertion_result_1.assertionCheck)('filename', 'filename', saved.suggested === data.filename, data.filename, saved.suggested));
|
|
254
|
+
if (data.size !== undefined)
|
|
255
|
+
checks.push((0, assertion_result_1.assertionCheck)('size', 'size', length === data.size, data.size, length));
|
|
256
|
+
if (data.minSize !== undefined)
|
|
257
|
+
checks.push((0, assertion_result_1.assertionCheck)('minSize', 'minimum size', length >= data.minSize, data.minSize, length));
|
|
258
|
+
if (data.contains !== undefined)
|
|
259
|
+
checks.push((0, assertion_result_1.assertionCheck)('contains', 'content contains', body.toString('utf8').includes(data.contains), data.contains, body.toString('utf8')));
|
|
260
|
+
if (data.sha256 !== undefined)
|
|
261
|
+
checks.push((0, assertion_result_1.assertionCheck)('sha256', 'SHA-256', sha256 === data.sha256, data.sha256, sha256));
|
|
262
|
+
if ('json' in data) {
|
|
263
|
+
try {
|
|
264
|
+
const actual = JSON.parse(body.toString('utf8'));
|
|
265
|
+
checks.push((0, assertion_result_1.assertionCheck)('json', 'JSON content', (0, node_util_1.isDeepStrictEqual)(actual, data.json), data.json, actual));
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
checks.push({ ...(0, assertion_result_1.assertionCheck)('json', 'JSON content', false, data.json, body.toString('utf8')), reason: 'Downloaded content is not valid JSON: ' + (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
(0, assertion_result_1.requireAssertion)('download.expect', 'saved download ' + JSON.stringify(data.name), checks, reporter, { reason: checks.some(check => check.status === 'failed') ? 'Download assertion failed.' : undefined });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`Unknown download action: ${action}`);
|
|
275
|
+
}
|
|
276
|
+
async disposeProfile(profile) {
|
|
277
|
+
const prefix = profile + ':';
|
|
278
|
+
for (const map of [this.events, this.downloads])
|
|
279
|
+
for (const [key, event] of map)
|
|
280
|
+
if (key.startsWith(prefix)) {
|
|
281
|
+
event.fail(new Error('Profile closed.'));
|
|
282
|
+
map.delete(key);
|
|
283
|
+
}
|
|
284
|
+
for (const map of [this.captured, this.savedDownloads])
|
|
285
|
+
for (const key of map.keys())
|
|
286
|
+
if (key.startsWith(prefix))
|
|
287
|
+
map.delete(key);
|
|
288
|
+
for (const [key, route] of this.routes)
|
|
289
|
+
if (key.startsWith(prefix)) {
|
|
290
|
+
await route.context.unroute(route.pattern, route.handler).catch(() => { });
|
|
291
|
+
this.routes.delete(key);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async dispose() {
|
|
295
|
+
this.generation++;
|
|
296
|
+
for (const armed of [...this.events.values(), ...this.downloads.values()])
|
|
297
|
+
armed.fail(new Error('Event cancelled during cleanup.'));
|
|
298
|
+
this.events.clear();
|
|
299
|
+
this.downloads.clear();
|
|
300
|
+
this.captured.clear();
|
|
301
|
+
this.savedDownloads.clear();
|
|
302
|
+
for (const route of this.routes.values())
|
|
303
|
+
await route.context.unroute(route.pattern, route.handler).catch(() => { });
|
|
304
|
+
this.routes.clear();
|
|
305
|
+
this.routeErrors = [];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
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,10 @@
|
|
|
1
|
+
import { type AssertionReporter } from '../reporting/assertion_result';
|
|
2
|
+
import type { Page } from 'playwright';
|
|
3
|
+
export declare class NativeFunctions {
|
|
4
|
+
private installed;
|
|
5
|
+
private pages;
|
|
6
|
+
private counter;
|
|
7
|
+
private key;
|
|
8
|
+
execute(page: Page, action: string, input: unknown, timeout: number, reporter?: AssertionReporter): Promise<void>;
|
|
9
|
+
dispose(): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NativeFunctions = void 0;
|
|
4
|
+
const assertion_result_1 = require("../reporting/assertion_result");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const validation_1 = require("./validation");
|
|
7
|
+
const node_util_1 = require("node:util");
|
|
8
|
+
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.');
|
|
9
|
+
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();
|
|
10
|
+
function install(data) {
|
|
11
|
+
const symbol = Symbol.for('flowstride.function-doubles');
|
|
12
|
+
const registry = window[symbol] ||= Object.create(null);
|
|
13
|
+
if (registry[data.name])
|
|
14
|
+
throw new Error('Function double name already exists.');
|
|
15
|
+
const keys = data.target.replace(/^window\./, '').split('.');
|
|
16
|
+
let object = window;
|
|
17
|
+
for (const key of keys.slice(0, -1))
|
|
18
|
+
object = object[key];
|
|
19
|
+
const key = keys.at(-1), original = object[key], own = Object.getOwnPropertyDescriptor(object, key);
|
|
20
|
+
if (typeof original !== 'function')
|
|
21
|
+
throw new Error(`Target is not a function: ${data.target}`);
|
|
22
|
+
const calls = [];
|
|
23
|
+
const replacement = function (...args) {
|
|
24
|
+
let snapshot;
|
|
25
|
+
try {
|
|
26
|
+
snapshot = JSON.parse(JSON.stringify(args));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
snapshot = args.map(value => String(value));
|
|
30
|
+
}
|
|
31
|
+
calls.push(snapshot);
|
|
32
|
+
if (data.mode === 'spy')
|
|
33
|
+
return original.apply(this, args);
|
|
34
|
+
if (data.mode === 'throw') {
|
|
35
|
+
const error = new Error(data.error || 'Stubbed function error');
|
|
36
|
+
if (data.async)
|
|
37
|
+
return Promise.reject(error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return data.async ? Promise.resolve(data.value) : data.value;
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(object, key, { configurable: true, writable: true, value: replacement });
|
|
43
|
+
registry[data.name] = { calls, restore: () => { if (own)
|
|
44
|
+
Object.defineProperty(object, key, own);
|
|
45
|
+
else
|
|
46
|
+
delete object[key]; } };
|
|
47
|
+
}
|
|
48
|
+
class NativeFunctions {
|
|
49
|
+
installed = new Map();
|
|
50
|
+
pages = new WeakMap();
|
|
51
|
+
counter = 0;
|
|
52
|
+
key(page, name) { if (!this.pages.has(page))
|
|
53
|
+
this.pages.set(page, ++this.counter); return `${this.pages.get(page)}:${name}`; }
|
|
54
|
+
async execute(page, action, input, timeout, reporter) {
|
|
55
|
+
if (action === 'install') {
|
|
56
|
+
const data = schema.parse(input);
|
|
57
|
+
if (this.installed.has(this.key(page, data.name)))
|
|
58
|
+
throw new Error('Function double name already exists; restore it first.');
|
|
59
|
+
const script = data.beforeLoad ? await page.addInitScript(install, data) : undefined;
|
|
60
|
+
try {
|
|
61
|
+
if (!data.beforeLoad || page.url() !== 'about:blank')
|
|
62
|
+
await page.evaluate(install, data);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
await script?.dispose();
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
this.installed.set(this.key(page, data.name), { name: data.name, target: data.target, page, script });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (action === 'restore') {
|
|
72
|
+
const data = zod_1.z.object({ name: validation_1.name }).strict().parse(input), entry = this.installed.get(this.key(page, data.name));
|
|
73
|
+
if (!entry || entry.page !== page)
|
|
74
|
+
throw new Error('Unknown function double on this page.');
|
|
75
|
+
await entry.script?.dispose();
|
|
76
|
+
await page.evaluate(name => { const registry = window[Symbol.for('flowstride.function-doubles')]; registry?.[name]?.restore(); if (registry)
|
|
77
|
+
delete registry[name]; }, data.name);
|
|
78
|
+
this.installed.delete(this.key(page, data.name));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (action === 'expect') {
|
|
82
|
+
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);
|
|
83
|
+
if (!['count', 'minCount', 'args'].some(key => key in data))
|
|
84
|
+
throw new Error('Provide a call count or expected arguments.');
|
|
85
|
+
const started = Date.now(), limit = data.timeout ?? timeout;
|
|
86
|
+
const sensitive = /auth|login|password|passcode|secret|token|cookie|api.?key|otp/i.test(this.installed.get(this.key(page, data.name))?.target || data.name);
|
|
87
|
+
let latest = (0, assertion_result_1.reportAssertion)('stub.expect', 'function double ' + JSON.stringify(data.name), [{ key: 'calls', label: 'Recorded calls available', status: 'running' }], reporter, { status: 'running', timeout: limit, elapsed: 0 });
|
|
88
|
+
let closed = false;
|
|
89
|
+
try {
|
|
90
|
+
await (0, validation_1.poll)(async () => {
|
|
91
|
+
const calls = await page.evaluate(name => window[Symbol.for('flowstride.function-doubles')]?.[name]?.calls, data.name);
|
|
92
|
+
const checks = [(0, assertion_result_1.assertionCheck)('calls', 'Recorded calls available', Array.isArray(calls), 'recorded calls', Array.isArray(calls) ? calls.length : 'No function double found on this page')];
|
|
93
|
+
if (Array.isArray(calls)) {
|
|
94
|
+
if (data.count !== undefined)
|
|
95
|
+
checks.push((0, assertion_result_1.assertionCheck)('count', 'call count', calls.length === data.count, data.count, calls.length));
|
|
96
|
+
if (data.minCount !== undefined)
|
|
97
|
+
checks.push((0, assertion_result_1.assertionCheck)('minCount', 'minimum call count', calls.length >= data.minCount, data.minCount, calls.length));
|
|
98
|
+
if (data.args !== undefined)
|
|
99
|
+
checks.push((0, assertion_result_1.assertionCheck)('args', 'matching call arguments', calls.some(call => (0, node_util_1.isDeepStrictEqual)(call, data.args)), sensitive ? "[REDACTED]" : data.args, sensitive ? "[REDACTED]" : calls));
|
|
100
|
+
}
|
|
101
|
+
if (!closed)
|
|
102
|
+
latest = (0, assertion_result_1.reportAssertion)('stub.expect', 'function double ' + JSON.stringify(data.name), checks, reporter, { status: 'running', timeout: limit, elapsed: Date.now() - started });
|
|
103
|
+
return checks.every(check => check.status === 'passed');
|
|
104
|
+
}, limit, `function double ${data.name}`);
|
|
105
|
+
closed = true;
|
|
106
|
+
reporter?.({ ...latest, status: 'passed', elapsed: Date.now() - started });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
closed = true;
|
|
110
|
+
const result = { ...latest, status: 'failed', elapsed: Date.now() - started, reason: (0, assertion_result_1.diagnosticValue)(error instanceof Error ? error.message : String(error)) };
|
|
111
|
+
if (!result.checks.some(check => check.status === 'failed'))
|
|
112
|
+
result.checks.push({ key: 'read', label: 'Recorded calls could be read', status: 'failed', reason: result.reason });
|
|
113
|
+
reporter?.(result);
|
|
114
|
+
throw new assertion_result_1.AssertionFailure(result);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`Unknown stub action: ${action}`);
|
|
119
|
+
}
|
|
120
|
+
async dispose() {
|
|
121
|
+
for (const entry of this.installed.values()) {
|
|
122
|
+
const name = entry.name;
|
|
123
|
+
await entry.script?.dispose().catch(() => { });
|
|
124
|
+
if (!entry.page.isClosed())
|
|
125
|
+
await entry.page.evaluate(name => { const registry = window[Symbol.for('flowstride.function-doubles')]; registry?.[name]?.restore(); if (registry)
|
|
126
|
+
delete registry[name]; }, name).catch(() => { });
|
|
127
|
+
}
|
|
128
|
+
this.installed.clear();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.NativeFunctions = NativeFunctions;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AssertionResult } from '../reporting/assertion_result';
|
|
2
|
+
import type { FlowstrideConfig } from '../types';
|
|
3
|
+
import { WebAdapter } from '../adapters/web/playwright';
|
|
4
|
+
import { NativeEvents } from './events';
|
|
5
|
+
export declare class NativeRuntime {
|
|
6
|
+
private web;
|
|
7
|
+
private config;
|
|
8
|
+
readonly events: NativeEvents;
|
|
9
|
+
lastAssertion?: AssertionResult;
|
|
10
|
+
private assertionGeneration;
|
|
11
|
+
private functions;
|
|
12
|
+
private components;
|
|
13
|
+
constructor(web: WebAdapter, config: FlowstrideConfig);
|
|
14
|
+
execute(command: string, input?: unknown): Promise<unknown>;
|
|
15
|
+
captureDOM(step: string): Promise<string | undefined>;
|
|
16
|
+
dispose(): Promise<void>;
|
|
17
|
+
}
|