flowstride 1.0.20 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NATIVE_BROWSER.md +341 -0
- package/NATIVE_CI.md +3 -3
- package/README.md +3 -3
- package/dist/adapters/api/undici.d.ts +8 -0
- package/dist/adapters/api/undici.js +11 -0
- package/dist/adapters/web/playwright.d.ts +22 -2
- package/dist/adapters/web/playwright.js +233 -36
- package/dist/ci/config.js +14 -1
- package/dist/ci/distribution.d.ts +38 -0
- package/dist/ci/distribution.js +134 -0
- package/dist/ci/init.d.ts +4 -1
- package/dist/ci/init.js +13 -2
- package/dist/ci/runtime.js +24 -5
- package/dist/cli/commands/run.js +3 -0
- package/dist/cli/index.js +24 -5
- package/dist/manual/security.d.ts +2 -2
- package/dist/native/accessibility.d.ts +3 -0
- package/dist/native/accessibility.js +68 -0
- package/dist/native/assertions.d.ts +2 -0
- package/dist/native/assertions.js +140 -0
- package/dist/native/compatibility.d.ts +3 -0
- package/dist/native/compatibility.js +16 -0
- package/dist/native/components.d.ts +12 -0
- package/dist/native/components.js +122 -0
- package/dist/native/dashboard.d.ts +7 -0
- package/dist/native/dashboard.js +32 -0
- package/dist/native/events.d.ts +34 -0
- package/dist/native/events.js +281 -0
- package/dist/native/evidence.d.ts +3 -0
- package/dist/native/evidence.js +31 -0
- package/dist/native/functions.d.ts +9 -0
- package/dist/native/functions.js +103 -0
- package/dist/native/runtime.d.ts +14 -0
- package/dist/native/runtime.js +154 -0
- package/dist/native/validation.d.ts +107 -0
- package/dist/native/validation.js +84 -0
- package/dist/parser/lexer.js +15 -4
- package/dist/parser/parser.js +1 -1
- package/dist/runner/flow_orchestrator.d.ts +4 -0
- package/dist/runner/flow_orchestrator.js +22 -5
- package/dist/runner/flow_worker.d.ts +7 -0
- package/dist/runner/flow_worker.js +102 -3
- package/dist/types/index.d.ts +10 -0
- package/package.json +20 -9
- package/src/ui/dist/assets/index-DglsL_J2.js +636 -0
- package/src/ui/dist/index.html +1 -1
- package/src/ui/dist/assets/index-B7MuBTWm.js +0 -460
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { BrowserContextOptions } from 'playwright';
|
|
3
|
+
import type { FlowstrideConfig } from '../types';
|
|
4
|
+
export declare const name: z.ZodString;
|
|
5
|
+
export declare const positive: z.ZodNumber;
|
|
6
|
+
export declare const headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
7
|
+
export declare const contextOptions: z.ZodObject<{
|
|
8
|
+
acceptDownloads: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
strictSelectors: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
+
clientCertificates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
11
|
+
origin: z.ZodString;
|
|
12
|
+
certPath: z.ZodOptional<z.ZodString>;
|
|
13
|
+
keyPath: z.ZodOptional<z.ZodString>;
|
|
14
|
+
pfxPath: z.ZodOptional<z.ZodString>;
|
|
15
|
+
passphrase: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, z.core.$strict>>>;
|
|
17
|
+
storageState: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
18
|
+
cookies: z.ZodArray<z.ZodObject<{
|
|
19
|
+
name: z.ZodString;
|
|
20
|
+
value: z.ZodString;
|
|
21
|
+
domain: z.ZodString;
|
|
22
|
+
path: z.ZodString;
|
|
23
|
+
expires: z.ZodNumber;
|
|
24
|
+
httpOnly: z.ZodBoolean;
|
|
25
|
+
secure: z.ZodBoolean;
|
|
26
|
+
sameSite: z.ZodEnum<{
|
|
27
|
+
Strict: "Strict";
|
|
28
|
+
Lax: "Lax";
|
|
29
|
+
None: "None";
|
|
30
|
+
}>;
|
|
31
|
+
}, z.core.$strict>>;
|
|
32
|
+
origins: z.ZodArray<z.ZodObject<{
|
|
33
|
+
origin: z.ZodString;
|
|
34
|
+
localStorage: z.ZodArray<z.ZodObject<{
|
|
35
|
+
name: z.ZodString;
|
|
36
|
+
value: z.ZodString;
|
|
37
|
+
}, z.core.$strict>>;
|
|
38
|
+
indexedDB: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
39
|
+
}, z.core.$strict>>;
|
|
40
|
+
}, z.core.$strict>]>>;
|
|
41
|
+
baseURL: z.ZodOptional<z.ZodString>;
|
|
42
|
+
bypassCSP: z.ZodOptional<z.ZodBoolean>;
|
|
43
|
+
colorScheme: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
44
|
+
light: "light";
|
|
45
|
+
dark: "dark";
|
|
46
|
+
"no-preference": "no-preference";
|
|
47
|
+
}>>>;
|
|
48
|
+
contrast: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
49
|
+
"no-preference": "no-preference";
|
|
50
|
+
more: "more";
|
|
51
|
+
}>>>;
|
|
52
|
+
deviceScaleFactor: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
extraHTTPHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
54
|
+
forcedColors: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
55
|
+
none: "none";
|
|
56
|
+
active: "active";
|
|
57
|
+
}>>>;
|
|
58
|
+
hasTouch: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
httpCredentials: z.ZodOptional<z.ZodObject<{
|
|
60
|
+
username: z.ZodString;
|
|
61
|
+
password: z.ZodString;
|
|
62
|
+
origin: z.ZodOptional<z.ZodString>;
|
|
63
|
+
send: z.ZodOptional<z.ZodEnum<{
|
|
64
|
+
always: "always";
|
|
65
|
+
unauthorized: "unauthorized";
|
|
66
|
+
}>>;
|
|
67
|
+
}, z.core.$strict>>;
|
|
68
|
+
geolocation: z.ZodOptional<z.ZodObject<{
|
|
69
|
+
latitude: z.ZodNumber;
|
|
70
|
+
longitude: z.ZodNumber;
|
|
71
|
+
accuracy: z.ZodOptional<z.ZodNumber>;
|
|
72
|
+
}, z.core.$strict>>;
|
|
73
|
+
ignoreHTTPSErrors: z.ZodOptional<z.ZodBoolean>;
|
|
74
|
+
isMobile: z.ZodOptional<z.ZodBoolean>;
|
|
75
|
+
javaScriptEnabled: z.ZodOptional<z.ZodBoolean>;
|
|
76
|
+
locale: z.ZodOptional<z.ZodString>;
|
|
77
|
+
offline: z.ZodOptional<z.ZodBoolean>;
|
|
78
|
+
permissions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
79
|
+
proxy: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
server: z.ZodString;
|
|
81
|
+
bypass: z.ZodOptional<z.ZodString>;
|
|
82
|
+
username: z.ZodOptional<z.ZodString>;
|
|
83
|
+
password: z.ZodOptional<z.ZodString>;
|
|
84
|
+
}, z.core.$strict>>;
|
|
85
|
+
reducedMotion: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
86
|
+
reduce: "reduce";
|
|
87
|
+
"no-preference": "no-preference";
|
|
88
|
+
}>>>;
|
|
89
|
+
screen: z.ZodOptional<z.ZodObject<{
|
|
90
|
+
width: z.ZodNumber;
|
|
91
|
+
height: z.ZodNumber;
|
|
92
|
+
}, z.core.$strict>>;
|
|
93
|
+
serviceWorkers: z.ZodOptional<z.ZodEnum<{
|
|
94
|
+
allow: "allow";
|
|
95
|
+
block: "block";
|
|
96
|
+
}>>;
|
|
97
|
+
timezoneId: z.ZodOptional<z.ZodString>;
|
|
98
|
+
userAgent: z.ZodOptional<z.ZodString>;
|
|
99
|
+
viewport: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
100
|
+
width: z.ZodNumber;
|
|
101
|
+
height: z.ZodNumber;
|
|
102
|
+
}, z.core.$strict>>>;
|
|
103
|
+
}, z.core.$strict>;
|
|
104
|
+
export declare function validateContextOptions(value: unknown, browser?: string, root?: string): BrowserContextOptions;
|
|
105
|
+
export declare function evidencePath(config: FlowstrideConfig, file: string): string;
|
|
106
|
+
export declare function readProjectFile(config: FlowstrideConfig, file: string, maxSize?: number): Buffer;
|
|
107
|
+
export declare function poll(read: () => Promise<boolean>, timeout: number, label: string): Promise<void>;
|
|
@@ -0,0 +1,84 @@
|
|
|
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.contextOptions = exports.headers = exports.positive = exports.name = void 0;
|
|
7
|
+
exports.validateContextOptions = validateContextOptions;
|
|
8
|
+
exports.evidencePath = evidencePath;
|
|
9
|
+
exports.readProjectFile = readProjectFile;
|
|
10
|
+
exports.poll = poll;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const zod_1 = require("zod");
|
|
14
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
15
|
+
exports.name = zod_1.z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/, 'Use a simple name without path separators.');
|
|
16
|
+
exports.positive = zod_1.z.number().int().min(1).max(2147483647);
|
|
17
|
+
exports.headers = zod_1.z.record(zod_1.z.string(), zod_1.z.string());
|
|
18
|
+
const size = zod_1.z.object({ width: exports.positive, height: exports.positive }).strict();
|
|
19
|
+
// File-bearing options are handled explicitly; evidence paths cannot bypass project containment.
|
|
20
|
+
exports.contextOptions = zod_1.z.object({
|
|
21
|
+
acceptDownloads: zod_1.z.boolean().optional(), strictSelectors: zod_1.z.boolean().optional(),
|
|
22
|
+
clientCertificates: zod_1.z.array(zod_1.z.object({ origin: zod_1.z.string().url(), certPath: zod_1.z.string().optional(), keyPath: zod_1.z.string().optional(), pfxPath: zod_1.z.string().optional(), passphrase: zod_1.z.string().optional() }).strict()).optional(),
|
|
23
|
+
storageState: zod_1.z.union([zod_1.z.string(), zod_1.z.object({ cookies: zod_1.z.array(zod_1.z.object({ name: zod_1.z.string(), value: zod_1.z.string(), domain: zod_1.z.string(), path: zod_1.z.string(), expires: zod_1.z.number(), httpOnly: zod_1.z.boolean(), secure: zod_1.z.boolean(), sameSite: zod_1.z.enum(['Strict', 'Lax', 'None']) }).strict()), origins: zod_1.z.array(zod_1.z.object({ origin: zod_1.z.string().url(), localStorage: zod_1.z.array(zod_1.z.object({ name: zod_1.z.string(), value: zod_1.z.string() }).strict()), indexedDB: zod_1.z.array(zod_1.z.unknown()).optional() }).strict()) }).strict()]).optional(),
|
|
24
|
+
baseURL: zod_1.z.string().url().optional(),
|
|
25
|
+
bypassCSP: zod_1.z.boolean().optional(), colorScheme: zod_1.z.enum(['light', 'dark', 'no-preference']).nullable().optional(),
|
|
26
|
+
contrast: zod_1.z.enum(['no-preference', 'more']).nullable().optional(),
|
|
27
|
+
deviceScaleFactor: zod_1.z.number().positive().optional(), extraHTTPHeaders: exports.headers.optional(),
|
|
28
|
+
forcedColors: zod_1.z.enum(['active', 'none']).nullable().optional(), hasTouch: zod_1.z.boolean().optional(),
|
|
29
|
+
httpCredentials: zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string(), origin: zod_1.z.string().url().optional(), send: zod_1.z.enum(['always', 'unauthorized']).optional() }).strict().optional(),
|
|
30
|
+
geolocation: 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().optional(),
|
|
31
|
+
ignoreHTTPSErrors: zod_1.z.boolean().optional(), isMobile: zod_1.z.boolean().optional(), javaScriptEnabled: zod_1.z.boolean().optional(),
|
|
32
|
+
locale: zod_1.z.string().min(1).optional(), offline: zod_1.z.boolean().optional(), permissions: zod_1.z.array(zod_1.z.string()).optional(),
|
|
33
|
+
proxy: zod_1.z.object({ server: zod_1.z.string().min(1), bypass: zod_1.z.string().optional(), username: zod_1.z.string().optional(), password: zod_1.z.string().optional() }).strict().optional(),
|
|
34
|
+
reducedMotion: zod_1.z.enum(['reduce', 'no-preference']).nullable().optional(), screen: size.optional(),
|
|
35
|
+
serviceWorkers: zod_1.z.enum(['allow', 'block']).optional(), timezoneId: zod_1.z.string().optional(),
|
|
36
|
+
userAgent: zod_1.z.string().optional(), viewport: size.nullable().optional(),
|
|
37
|
+
}).strict();
|
|
38
|
+
function validateContextOptions(value, browser = 'chromium', root = process.cwd()) {
|
|
39
|
+
const parsed = exports.contextOptions.parse(value ?? {});
|
|
40
|
+
if (typeof parsed.storageState === 'string')
|
|
41
|
+
parsed.storageState = (0, projectPaths_1.projectPath)(root, parsed.storageState);
|
|
42
|
+
for (const certificate of parsed.clientCertificates || [])
|
|
43
|
+
for (const key of ['certPath', 'keyPath', 'pfxPath'])
|
|
44
|
+
if (certificate[key])
|
|
45
|
+
certificate[key] = (0, projectPaths_1.projectPath)(root, certificate[key]);
|
|
46
|
+
if (browser === 'firefox' && parsed.isMobile)
|
|
47
|
+
throw new Error('Firefox does not support mobile emulation; use viewport and touch options instead.');
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
function evidencePath(config, file) {
|
|
51
|
+
const safe = exports.name.parse(file);
|
|
52
|
+
const root = config.projectRoot || process.cwd();
|
|
53
|
+
const dir = (0, projectPaths_1.projectPath)(root, config.artifactsDir || 'flowstride-artifacts');
|
|
54
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
return (0, projectPaths_1.projectPath)(root, node_path_1.default.join(dir, (0, projectPaths_1.artifactPrefix)(config.testFile) + safe));
|
|
56
|
+
}
|
|
57
|
+
function readProjectFile(config, file, maxSize = 20 * 1024 * 1024) {
|
|
58
|
+
const absolute = (0, projectPaths_1.projectPath)(config.projectRoot || process.cwd(), file);
|
|
59
|
+
if (!node_fs_1.default.statSync(absolute).isFile() || node_fs_1.default.statSync(absolute).size > maxSize)
|
|
60
|
+
throw new Error('Fixture must be a regular project file within the size limit.');
|
|
61
|
+
return node_fs_1.default.readFileSync(absolute);
|
|
62
|
+
}
|
|
63
|
+
async function poll(read, timeout, label) {
|
|
64
|
+
const deadline = Date.now() + exports.positive.parse(timeout);
|
|
65
|
+
let last;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const remaining = deadline - Date.now();
|
|
68
|
+
if (remaining <= 0)
|
|
69
|
+
throw new Error(`Assertion timed out: ${label}`, { cause: last });
|
|
70
|
+
let timer;
|
|
71
|
+
try {
|
|
72
|
+
const ok = await Promise.race([read(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Read timed out')), remaining); })]);
|
|
73
|
+
if (ok)
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
last = error;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/parser/lexer.js
CHANGED
|
@@ -53,11 +53,22 @@ class Lexer {
|
|
|
53
53
|
let jsonStr = "";
|
|
54
54
|
let depth = 0;
|
|
55
55
|
let startLine = this.line;
|
|
56
|
+
let inString = false, escaped = false;
|
|
56
57
|
while (this.position < this.input.length) {
|
|
57
58
|
const c = this.input[this.position];
|
|
58
|
-
if (
|
|
59
|
+
if (inString) {
|
|
60
|
+
if (escaped)
|
|
61
|
+
escaped = false;
|
|
62
|
+
else if (c === "\\")
|
|
63
|
+
escaped = true;
|
|
64
|
+
else if (c === '"')
|
|
65
|
+
inString = false;
|
|
66
|
+
}
|
|
67
|
+
else if (c === '"')
|
|
68
|
+
inString = true;
|
|
69
|
+
else if (c === "{")
|
|
59
70
|
depth++;
|
|
60
|
-
if (c === "}")
|
|
71
|
+
else if (c === "}")
|
|
61
72
|
depth--;
|
|
62
73
|
if (c === "\n")
|
|
63
74
|
this.line++;
|
|
@@ -217,7 +228,7 @@ class Lexer {
|
|
|
217
228
|
});
|
|
218
229
|
continue;
|
|
219
230
|
}
|
|
220
|
-
else if (ident.includes(".") && !lowerIdent.startsWith("flow.")) {
|
|
231
|
+
else if (/^flow\.(browser|assert|network|download|clock|stub|trace|har|a11y|component)\.[a-z]+$/i.test(ident) || (ident.includes(".") && !lowerIdent.startsWith("flow."))) {
|
|
221
232
|
tokens.push({
|
|
222
233
|
type: TokenType.ALIAS_COMMAND,
|
|
223
234
|
value: ident,
|
|
@@ -392,7 +403,7 @@ class Lexer {
|
|
|
392
403
|
line: this.line,
|
|
393
404
|
});
|
|
394
405
|
}
|
|
395
|
-
else if (ident.includes(".") && !lowerIdent.startsWith("flow.")) {
|
|
406
|
+
else if (/^flow\.(browser|assert|network|download|clock|stub|trace|har|a11y|component)\.[a-z]+$/i.test(ident) || (ident.includes(".") && !lowerIdent.startsWith("flow."))) {
|
|
396
407
|
tokens.push({
|
|
397
408
|
type: TokenType.ALIAS_COMMAND,
|
|
398
409
|
value: ident,
|
package/dist/parser/parser.js
CHANGED
|
@@ -242,7 +242,7 @@ class Parser {
|
|
|
242
242
|
return {
|
|
243
243
|
id: `scenario_${this.scenarioCounter}_S${this.stepCounter++}`,
|
|
244
244
|
blockDescription: this.currentBlockDescription,
|
|
245
|
-
action: "plugin",
|
|
245
|
+
action: alias === "flow" ? "native" : "plugin",
|
|
246
246
|
payload: {
|
|
247
247
|
pluginAlias: alias,
|
|
248
248
|
pluginMethod: method,
|
|
@@ -4,6 +4,7 @@ export declare class FlowOrchestrator {
|
|
|
4
4
|
private worker;
|
|
5
5
|
private workerCount;
|
|
6
6
|
private config?;
|
|
7
|
+
static readonly nativeBrowserProtocol = 2;
|
|
7
8
|
static readonly nativeCIProtocol = 1;
|
|
8
9
|
readonly nativeEventProtocol = 1;
|
|
9
10
|
private wss;
|
|
@@ -12,6 +13,9 @@ export declare class FlowOrchestrator {
|
|
|
12
13
|
private stepStatusRegistry;
|
|
13
14
|
private pdfGenerator;
|
|
14
15
|
private disconnectTimer;
|
|
16
|
+
private dashboardBrowser;
|
|
17
|
+
private closing;
|
|
18
|
+
private browserInfoByFile;
|
|
15
19
|
constructor(worker: FlowWorker, workerCount?: number, config?: FlowstrideConfig | undefined);
|
|
16
20
|
private forwardToDashboard;
|
|
17
21
|
private executeTestRun;
|
|
@@ -37,8 +37,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.FlowOrchestrator = void 0;
|
|
40
|
+
const dashboard_1 = require("../native/dashboard");
|
|
41
|
+
const evidence_1 = require("../native/evidence");
|
|
42
|
+
const distribution_1 = require("../ci/distribution");
|
|
40
43
|
const localDashboard_1 = require("../security/localDashboard");
|
|
41
|
-
const projectPaths_1 = require("../utils/projectPaths");
|
|
42
44
|
const fs = __importStar(require("fs"));
|
|
43
45
|
const path = __importStar(require("path"));
|
|
44
46
|
const http = __importStar(require("http"));
|
|
@@ -50,6 +52,7 @@ class FlowOrchestrator {
|
|
|
50
52
|
worker;
|
|
51
53
|
workerCount;
|
|
52
54
|
config;
|
|
55
|
+
static nativeBrowserProtocol = 2;
|
|
53
56
|
static nativeCIProtocol = 1;
|
|
54
57
|
nativeEventProtocol = 1;
|
|
55
58
|
wss = null;
|
|
@@ -58,6 +61,9 @@ class FlowOrchestrator {
|
|
|
58
61
|
stepStatusRegistry = new Map();
|
|
59
62
|
pdfGenerator;
|
|
60
63
|
disconnectTimer = null;
|
|
64
|
+
dashboardBrowser = new dashboard_1.DashboardBrowser();
|
|
65
|
+
closing = false;
|
|
66
|
+
browserInfoByFile = new Map();
|
|
61
67
|
constructor(worker, workerCount = 1, config) {
|
|
62
68
|
this.worker = worker;
|
|
63
69
|
this.workerCount = workerCount;
|
|
@@ -68,6 +74,10 @@ class FlowOrchestrator {
|
|
|
68
74
|
});
|
|
69
75
|
}
|
|
70
76
|
forwardToDashboard(type, payload) {
|
|
77
|
+
if (type === 'BROWSER_INFO')
|
|
78
|
+
this.browserInfoByFile.set(payload.fileName, payload);
|
|
79
|
+
if (type === 'SUITE_START')
|
|
80
|
+
this.browserInfoByFile.clear();
|
|
71
81
|
this.config?.onRunEvent?.({ type, payload });
|
|
72
82
|
if (this.wss) {
|
|
73
83
|
this.wss.clients.forEach((client) => {
|
|
@@ -78,7 +88,7 @@ class FlowOrchestrator {
|
|
|
78
88
|
}
|
|
79
89
|
async executeTestRun(targetPath) {
|
|
80
90
|
this.forwardToDashboard("SUITE_START", {});
|
|
81
|
-
const filesToProcess = (0,
|
|
91
|
+
const filesToProcess = (0, distribution_1.executionPlan)(targetPath, this.config).files;
|
|
82
92
|
if (this.config)
|
|
83
93
|
this.config.flowRoot = fs.statSync(targetPath).isDirectory() ? targetPath : path.dirname(targetPath);
|
|
84
94
|
this.globalTestScenarios = [];
|
|
@@ -115,6 +125,8 @@ class FlowOrchestrator {
|
|
|
115
125
|
return;
|
|
116
126
|
}
|
|
117
127
|
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
128
|
+
if ((0, evidence_1.serveDOMSnapshot)(req, res, this.config))
|
|
129
|
+
return;
|
|
118
130
|
if (req.url && req.url.startsWith("/videos/")) {
|
|
119
131
|
let urlObj;
|
|
120
132
|
try {
|
|
@@ -229,6 +241,8 @@ class FlowOrchestrator {
|
|
|
229
241
|
});
|
|
230
242
|
this.wss = new ws_1.WebSocketServer({ server: this.httpServer, verifyClient: (info) => Boolean(info.origin) && (0, localDashboard_1.isLocalDashboardRequest)(info.req.headers.host, info.origin) });
|
|
231
243
|
this.wss.on("connection", (ws) => {
|
|
244
|
+
for (const payload of this.browserInfoByFile.values())
|
|
245
|
+
ws.send(JSON.stringify({ type: 'BROWSER_INFO', payload }));
|
|
232
246
|
if (this.disconnectTimer) {
|
|
233
247
|
clearTimeout(this.disconnectTimer);
|
|
234
248
|
this.disconnectTimer = null;
|
|
@@ -267,7 +281,7 @@ class FlowOrchestrator {
|
|
|
267
281
|
}
|
|
268
282
|
});
|
|
269
283
|
ws.on("close", () => {
|
|
270
|
-
if (this.wss && this.wss.clients.size === 0) {
|
|
284
|
+
if (!this.closing && this.wss && this.wss.clients.size === 0) {
|
|
271
285
|
this.disconnectTimer = setTimeout(async () => {
|
|
272
286
|
console.log(chalk_1.default.yellow("\n[Flowstride]: Dashboard disconnected. Shutting down framework..."));
|
|
273
287
|
this.worker.abort();
|
|
@@ -300,8 +314,8 @@ class FlowOrchestrator {
|
|
|
300
314
|
!!process.env.GITHUB_ACTIONS ||
|
|
301
315
|
!!process.env.FLOWSTRIDE_API_KEY;
|
|
302
316
|
if (!isStrictHeadless) {
|
|
303
|
-
|
|
304
|
-
|
|
317
|
+
await this.dashboardBrowser.open(localUrl, this.config?.browser);
|
|
318
|
+
console.log(`[Dashboard]: ${this.config?.browser === 'firefox' || this.config?.browser === 'webkit' ? this.config.browser : 'system default'} dashboard window; test browser reported after launch${this.config?.headless ? ' (headless tests)' : ''}.`);
|
|
305
319
|
console.log(chalk_1.default.yellow("[Dashboard]: Waiting for UI connection..."));
|
|
306
320
|
while (this.wss.clients.size === 0)
|
|
307
321
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -314,6 +328,7 @@ class FlowOrchestrator {
|
|
|
314
328
|
resolve(result);
|
|
315
329
|
}
|
|
316
330
|
catch (err) {
|
|
331
|
+
await this.dashboardBrowser.close();
|
|
317
332
|
reject(err);
|
|
318
333
|
}
|
|
319
334
|
});
|
|
@@ -327,6 +342,8 @@ class FlowOrchestrator {
|
|
|
327
342
|
this.worker.abort();
|
|
328
343
|
}
|
|
329
344
|
async close() {
|
|
345
|
+
this.closing = true;
|
|
346
|
+
await this.dashboardBrowser.close();
|
|
330
347
|
if (this.disconnectTimer)
|
|
331
348
|
clearTimeout(this.disconnectTimer);
|
|
332
349
|
this.wss?.clients.forEach(client => client.terminate());
|
|
@@ -19,10 +19,16 @@ export declare class FlowWorker extends EventEmitter {
|
|
|
19
19
|
private store;
|
|
20
20
|
private sessions;
|
|
21
21
|
private pluginManager?;
|
|
22
|
+
static readonly nativeBrowserProtocol = 2;
|
|
22
23
|
private isSilent;
|
|
23
24
|
private isAborted;
|
|
24
25
|
private bailTriggered;
|
|
25
26
|
private isDebugHalted;
|
|
27
|
+
private native;
|
|
28
|
+
private attempt;
|
|
29
|
+
private maxAttempts;
|
|
30
|
+
private fallbackStream?;
|
|
31
|
+
private interceptedContexts;
|
|
26
32
|
private currentStepContext;
|
|
27
33
|
private currentFileName;
|
|
28
34
|
private activeAudioPath;
|
|
@@ -46,6 +52,7 @@ export declare class FlowWorker extends EventEmitter {
|
|
|
46
52
|
handleReplayRequest(payload: any): Promise<void>;
|
|
47
53
|
abort(): void;
|
|
48
54
|
runBatch(filesToProcess: string[]): Promise<void>;
|
|
55
|
+
private runScenarioWithRetries;
|
|
49
56
|
private runScenario;
|
|
50
57
|
private waitForPipeline;
|
|
51
58
|
private executeStep;
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.FlowWorker = void 0;
|
|
40
|
+
const runtime_1 = require("../native/runtime");
|
|
40
41
|
const deadline_1 = require("../ci/deadline");
|
|
41
42
|
const replayRequest_1 = require("../utils/replayRequest");
|
|
42
43
|
const projectPaths_1 = require("../utils/projectPaths");
|
|
@@ -57,10 +58,16 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
57
58
|
store;
|
|
58
59
|
sessions;
|
|
59
60
|
pluginManager;
|
|
61
|
+
static nativeBrowserProtocol = 2;
|
|
60
62
|
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
61
63
|
isAborted = false;
|
|
62
64
|
bailTriggered = false;
|
|
63
65
|
isDebugHalted = false;
|
|
66
|
+
native;
|
|
67
|
+
attempt = 1;
|
|
68
|
+
maxAttempts = 1;
|
|
69
|
+
fallbackStream;
|
|
70
|
+
interceptedContexts = new WeakSet();
|
|
64
71
|
currentStepContext = "Initializing...";
|
|
65
72
|
currentFileName = "System";
|
|
66
73
|
activeAudioPath = undefined;
|
|
@@ -79,8 +86,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
79
86
|
this.store = store;
|
|
80
87
|
this.sessions = sessions;
|
|
81
88
|
this.pluginManager = pluginManager;
|
|
89
|
+
this.native = new runtime_1.NativeRuntime(web, config);
|
|
90
|
+
this.web.onPageChanged = async () => { await this.startStreaming(); await this.setupNetworkInterception(); };
|
|
82
91
|
}
|
|
83
92
|
broadcast(type, payload) {
|
|
93
|
+
if (["STEP_ADD", "STEP_UPDATE", "LOG_EVENT"].includes(type))
|
|
94
|
+
payload = { ...payload, attempt: this.attempt, profile: this.web.activeProfile || "default" };
|
|
84
95
|
const secrets = [this.config.machineToken || '', ...(this.dataGen?.sensitiveValues?.() || []), ...Object.entries(process.env).filter(([key]) => /password|token|secret|api.?key/i.test(key)).map(([, value]) => String(value)), ...Object.entries(this.localVariables).filter(([key]) => /password|passcode|token|secret|otp|api.?key/i.test(key)).map(([, value]) => String(value))];
|
|
85
96
|
this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
|
|
86
97
|
}
|
|
@@ -394,7 +405,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
394
405
|
this.localBrowserSessions = {};
|
|
395
406
|
this.api.resetState();
|
|
396
407
|
this.dataGen = new data_generators_1.DataGenerators();
|
|
408
|
+
await this.native.dispose();
|
|
397
409
|
await this.web.initialise(this.activeAudioPath);
|
|
410
|
+
if (typeof this.web.getBrowserInfo === 'function') {
|
|
411
|
+
const browser = this.web.getBrowserInfo();
|
|
412
|
+
console.log(`[Browser]: ${browser.engine} ${browser.version}; ${browser.headless ? 'headless' : 'headed'}; ${fileName}`);
|
|
413
|
+
this.broadcast('BROWSER_INFO', { ...browser, fileName });
|
|
414
|
+
}
|
|
398
415
|
await this.startStreaming();
|
|
399
416
|
await this.setupNetworkInterception();
|
|
400
417
|
const content = fs.readFileSync(filePath, "utf8");
|
|
@@ -453,7 +470,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
453
470
|
scenarioName: s.name,
|
|
454
471
|
fileName: this.currentFileName,
|
|
455
472
|
});
|
|
456
|
-
await this.
|
|
473
|
+
await this.runScenarioWithRetries(s, f.declarations);
|
|
457
474
|
if (this.isDebugHalted || this.bailTriggered)
|
|
458
475
|
break;
|
|
459
476
|
await this.web
|
|
@@ -482,6 +499,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
482
499
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
483
500
|
for (const message of this.web.pageErrors || [])
|
|
484
501
|
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
502
|
+
await this.native.dispose();
|
|
485
503
|
await this.web.shutdown(targetVideoPath);
|
|
486
504
|
if (!this.isSilent)
|
|
487
505
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -505,6 +523,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
505
523
|
const targetVideoPath = path.join(mockCloudDir, `${baseName}.webm`);
|
|
506
524
|
for (const message of this.web.pageErrors || [])
|
|
507
525
|
this.broadcast('BROWSER_ERROR', { message, fileName: this.currentFileName });
|
|
526
|
+
await this.native.dispose();
|
|
508
527
|
await this.web.shutdown(targetVideoPath);
|
|
509
528
|
if (!this.isSilent)
|
|
510
529
|
console.log(chalk_1.default.gray(` [Cloud Sync]: Video safely encoded and preserved for ${this.currentFileName}`));
|
|
@@ -531,6 +550,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
531
550
|
try {
|
|
532
551
|
const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
|
|
533
552
|
fs.mkdirSync(directory, { recursive: true });
|
|
553
|
+
await this.native.dispose();
|
|
534
554
|
await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
|
|
535
555
|
}
|
|
536
556
|
catch (error) {
|
|
@@ -540,9 +560,52 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
540
560
|
process.removeListener("SIGINT", sigintHandler);
|
|
541
561
|
}
|
|
542
562
|
}
|
|
563
|
+
async runScenarioWithRetries(scenario, declarations) {
|
|
564
|
+
this.maxAttempts = (this.config.retries || 0) + 1;
|
|
565
|
+
const baseline = this.maxAttempts > 1 ? await this.web.checkpointProfiles() : undefined;
|
|
566
|
+
const variables = this.maxAttempts > 1 ? structuredClone(this.localVariables) : {}, sessions = this.maxAttempts > 1 ? structuredClone(this.localBrowserSessions) : {};
|
|
567
|
+
const globals = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalVariables) : {}, globalSessions = this.maxAttempts > 1 ? structuredClone(FlowWorker.globalBrowserSessions) : {};
|
|
568
|
+
const storeState = this.maxAttempts > 1 ? structuredClone(this.store.getAll?.()) : undefined;
|
|
569
|
+
const apiState = this.maxAttempts > 1 ? this.api.checkpointState?.() : undefined;
|
|
570
|
+
for (this.attempt = 1; this.attempt <= this.maxAttempts; this.attempt++) {
|
|
571
|
+
if (this.isAborted || this.isDebugHalted)
|
|
572
|
+
break;
|
|
573
|
+
if (this.attempt > 1) {
|
|
574
|
+
await this.native.dispose();
|
|
575
|
+
if (apiState)
|
|
576
|
+
this.api.restoreState(apiState);
|
|
577
|
+
else
|
|
578
|
+
this.api.resetState();
|
|
579
|
+
this.localVariables = structuredClone(variables);
|
|
580
|
+
this.localBrowserSessions = structuredClone(sessions);
|
|
581
|
+
FlowWorker.globalVariables = structuredClone(globals);
|
|
582
|
+
FlowWorker.globalBrowserSessions = structuredClone(globalSessions);
|
|
583
|
+
if (storeState) {
|
|
584
|
+
this.store.clear();
|
|
585
|
+
for (const [key, value] of Object.entries(structuredClone(storeState)))
|
|
586
|
+
this.store.set(key, value);
|
|
587
|
+
}
|
|
588
|
+
await this.web.restoreProfiles(baseline);
|
|
589
|
+
}
|
|
590
|
+
const started = Date.now();
|
|
591
|
+
this.broadcast('SCENARIO_ATTEMPT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt });
|
|
592
|
+
const errorIndex = this.web.pageErrors?.length || 0;
|
|
593
|
+
const completed = await this.runScenario(structuredClone(scenario), declarations);
|
|
594
|
+
const browserErrors = this.web.pageErrors?.slice(errorIndex) || [];
|
|
595
|
+
const passed = completed && (this.config.browserErrors === 'record' || !browserErrors.length);
|
|
596
|
+
this.broadcast('SCENARIO_RESULT', { fileName: this.currentFileName, scenario: scenario.name, attempt: this.attempt, status: this.isAborted ? 'interrupted' : passed ? 'passed' : 'failed', flaky: passed && this.attempt > 1, browserErrors, duration: Date.now() - started });
|
|
597
|
+
if (passed || this.isAborted || this.isDebugHalted)
|
|
598
|
+
break;
|
|
599
|
+
if (this.attempt < this.maxAttempts)
|
|
600
|
+
this.web.pageErrors?.splice(errorIndex);
|
|
601
|
+
}
|
|
602
|
+
this.attempt = 1;
|
|
603
|
+
this.maxAttempts = 1;
|
|
604
|
+
}
|
|
543
605
|
async runScenario(scenario, globalDeclarations = []) {
|
|
544
606
|
let hasScenarioFailed = false;
|
|
545
|
-
for (const
|
|
607
|
+
for (const originalStep of scenario.steps) {
|
|
608
|
+
const step = structuredClone(originalStep);
|
|
546
609
|
if (this.isAborted)
|
|
547
610
|
break;
|
|
548
611
|
const blockName = step.blockDescription.split(" ")[0];
|
|
@@ -597,11 +660,13 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
597
660
|
try {
|
|
598
661
|
await this.executeStep(step.action, step.payload, scenario.declarations, globalDeclarations);
|
|
599
662
|
const stepContext = await this.captureStepContext(step.payload);
|
|
663
|
+
const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
|
|
600
664
|
spinner.succeed();
|
|
601
665
|
this.broadcast("STEP_UPDATE", {
|
|
602
666
|
id: step.id,
|
|
603
667
|
status: "passed",
|
|
604
668
|
screenshot: stepContext.screenshot,
|
|
669
|
+
domSnapshot,
|
|
605
670
|
fileName: this.currentFileName,
|
|
606
671
|
});
|
|
607
672
|
if (step.isStop) {
|
|
@@ -616,6 +681,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
616
681
|
if (this.isAborted)
|
|
617
682
|
break;
|
|
618
683
|
const stepContext = await this.captureStepContext(step.payload);
|
|
684
|
+
const domSnapshot = await this.native.captureDOM(step.id).catch(() => undefined);
|
|
619
685
|
let strippedDom = "";
|
|
620
686
|
try {
|
|
621
687
|
const page = this.web.getPage();
|
|
@@ -637,6 +703,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
637
703
|
id: step.id,
|
|
638
704
|
status: "skipped",
|
|
639
705
|
screenshot: stepContext.screenshot,
|
|
706
|
+
domSnapshot,
|
|
640
707
|
fileName: this.currentFileName,
|
|
641
708
|
});
|
|
642
709
|
continue;
|
|
@@ -684,15 +751,17 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
684
751
|
id: step.id,
|
|
685
752
|
status: "failed",
|
|
686
753
|
screenshot: stepContext.screenshot,
|
|
754
|
+
domSnapshot,
|
|
687
755
|
fileName: this.currentFileName,
|
|
688
756
|
});
|
|
689
757
|
hasScenarioFailed = true;
|
|
690
|
-
if (this.config.bail && !this.bailTriggered) {
|
|
758
|
+
if (this.config.bail && this.attempt >= this.maxAttempts && !this.bailTriggered) {
|
|
691
759
|
this.bailTriggered = true;
|
|
692
760
|
this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
|
|
693
761
|
}
|
|
694
762
|
}
|
|
695
763
|
}
|
|
764
|
+
return !hasScenarioFailed;
|
|
696
765
|
}
|
|
697
766
|
async waitForPipeline() {
|
|
698
767
|
if (!this.activeAudioPath || !fs.existsSync(this.activeAudioPath)) {
|
|
@@ -739,6 +808,12 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
739
808
|
].join(", ");
|
|
740
809
|
}
|
|
741
810
|
switch (action) {
|
|
811
|
+
case 'native': {
|
|
812
|
+
const result = await (0, deadline_1.withDeadline)(this.native.execute(payload.pluginMethod, payload.data), this.config.timeout);
|
|
813
|
+
if (payload.saveAs)
|
|
814
|
+
this.localVariables[payload.saveAs] = result;
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
742
817
|
case "extract":
|
|
743
818
|
if (["resBody", "resHeader", "cookie"].includes(payload.target)) {
|
|
744
819
|
this.processExtractions([
|
|
@@ -1181,6 +1256,9 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1181
1256
|
}
|
|
1182
1257
|
async setupNetworkInterception() {
|
|
1183
1258
|
const context = this.web.getPage().context();
|
|
1259
|
+
if (this.interceptedContexts.has(context))
|
|
1260
|
+
return;
|
|
1261
|
+
this.interceptedContexts.add(context);
|
|
1184
1262
|
context.on("response", async (response) => {
|
|
1185
1263
|
const lockedFileName = this.currentFileName;
|
|
1186
1264
|
const request = response.request();
|
|
@@ -1239,8 +1317,27 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1239
1317
|
});
|
|
1240
1318
|
}
|
|
1241
1319
|
async startStreaming() {
|
|
1320
|
+
clearInterval(this.fallbackStream);
|
|
1242
1321
|
if (this.config.ci)
|
|
1243
1322
|
return;
|
|
1323
|
+
if (this.config.browser && this.config.browser !== 'chromium') {
|
|
1324
|
+
const page = this.web.getPage();
|
|
1325
|
+
let busy = false;
|
|
1326
|
+
this.fallbackStream = setInterval(async () => {
|
|
1327
|
+
if (busy || page.isClosed())
|
|
1328
|
+
return;
|
|
1329
|
+
busy = true;
|
|
1330
|
+
try {
|
|
1331
|
+
this.broadcast('BROWSER_FRAME', { base64: (await page.screenshot({ type: 'jpeg', quality: 60 })).toString('base64'), fileName: this.currentFileName });
|
|
1332
|
+
}
|
|
1333
|
+
catch { }
|
|
1334
|
+
finally {
|
|
1335
|
+
busy = false;
|
|
1336
|
+
}
|
|
1337
|
+
}, 250);
|
|
1338
|
+
this.fallbackStream.unref();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1244
1341
|
const page = this.web.getPage();
|
|
1245
1342
|
if (this.currentCdpSession) {
|
|
1246
1343
|
await this.currentCdpSession.detach().catch(() => { });
|
|
@@ -1261,6 +1358,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1261
1358
|
});
|
|
1262
1359
|
}
|
|
1263
1360
|
async shutdown() {
|
|
1361
|
+
clearInterval(this.fallbackStream);
|
|
1362
|
+
await this.native.dispose();
|
|
1264
1363
|
try {
|
|
1265
1364
|
await this.web.shutdown();
|
|
1266
1365
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export interface FlowstrideConfig {
|
|
2
|
+
browser?: 'chromium' | 'firefox' | 'webkit';
|
|
3
|
+
browserOptions?: import('playwright').BrowserContextOptions;
|
|
4
|
+
retries?: number;
|
|
5
|
+
failOnFlaky?: boolean;
|
|
6
|
+
trace?: boolean;
|
|
7
|
+
har?: boolean;
|
|
8
|
+
domSnapshots?: boolean;
|
|
9
|
+
shard?: string;
|
|
10
|
+
shardHistory?: string;
|
|
11
|
+
updateBaselines?: boolean;
|
|
2
12
|
ci?: boolean;
|
|
3
13
|
flowRoot?: string;
|
|
4
14
|
projectRoot?: string;
|