flowstride 1.0.20-rc.2 → 1.0.20
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_CI.md +88 -145
- package/README.md +46 -221
- package/dist/adapters/web/playwright.d.ts +4 -1
- package/dist/adapters/web/playwright.js +75 -6
- package/dist/ci/config.js +6 -5
- package/dist/ci/runtime.js +27 -14
- package/dist/cli/commands/run.js +2 -1
- package/dist/cli/index.js +35 -50
- package/dist/cli/manual_server/workspace_server.js +44 -32
- package/dist/cli/utils/cloudSession.d.ts +7 -0
- package/dist/cli/utils/cloudSession.js +37 -0
- package/dist/cli/utils/proInstaller.d.ts +5 -0
- package/dist/cli/utils/proInstaller.js +141 -0
- package/dist/game/sdk.js +3 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/manual/security.d.ts +183 -0
- package/dist/manual/security.js +106 -0
- package/dist/runner/data_generators.d.ts +1 -0
- package/dist/runner/data_generators.js +3 -0
- package/dist/runner/dom_stripper.js +3 -0
- package/dist/runner/flow_orchestrator.js +13 -22
- package/dist/runner/flow_worker.d.ts +1 -0
- package/dist/runner/flow_worker.js +68 -47
- package/dist/runner/persistent_vault.js +2 -1
- package/dist/runner/plugin_manager.js +3 -1
- package/dist/security/localDashboard.d.ts +2 -0
- package/dist/security/localDashboard.js +22 -0
- package/dist/security/redaction.d.ts +4 -0
- package/dist/security/redaction.js +51 -0
- package/dist/sessions/manager.js +3 -1
- package/dist/types/index.d.ts +9 -0
- package/dist/utils/cloud.d.ts +6 -0
- package/dist/utils/cloud.js +33 -0
- package/dist/utils/projectPaths.d.ts +11 -0
- package/dist/utils/projectPaths.js +90 -0
- package/dist/utils/replayRequest.d.ts +8 -0
- package/dist/utils/replayRequest.js +77 -0
- package/package.json +23 -14
- package/src/ui/dist/assets/index-B7MuBTWm.js +460 -0
- package/src/ui/dist/index.html +1 -1
- package/RELEASE_NOTES.md +0 -40
- package/src/ui/dist/assets/index-DIC22ZK_.js +0 -638
|
@@ -0,0 +1,106 @@
|
|
|
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.ManualInputError = exports.manualCanvasSchema = exports.manualRunSchema = void 0;
|
|
7
|
+
exports.sanitizeManualHtml = sanitizeManualHtml;
|
|
8
|
+
exports.sanitizeStoredCanvas = sanitizeStoredCanvas;
|
|
9
|
+
exports.readManualPayload = readManualPayload;
|
|
10
|
+
const sanitize_html_1 = __importDefault(require("sanitize-html"));
|
|
11
|
+
const zod_1 = require("zod");
|
|
12
|
+
const MANUAL_HTML_TAGS = ["b", "i", "em", "strong", "a", "ul", "ol", "li", "br", "div", "p", "font"];
|
|
13
|
+
function sanitizeManualHtml(value) {
|
|
14
|
+
return (0, sanitize_html_1.default)(typeof value === "string" ? value : "", {
|
|
15
|
+
allowedTags: MANUAL_HTML_TAGS,
|
|
16
|
+
allowedAttributes: { a: ["href", "target", "rel"], font: ["size"] },
|
|
17
|
+
allowedSchemes: ["http", "https", "mailto"], allowProtocolRelative: false,
|
|
18
|
+
transformTags: { a: (tagName, attribs) => ({ tagName, attribs: {
|
|
19
|
+
...attribs, target: attribs.target === "_blank" ? "_blank" : "_self", rel: "noopener noreferrer",
|
|
20
|
+
} }) },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const label = zod_1.z.string().max(2000);
|
|
24
|
+
const rich = zod_1.z.string().max(100000).transform(sanitizeManualHtml);
|
|
25
|
+
const numeric = zod_1.z.number().finite();
|
|
26
|
+
const media = zod_1.z.string().max(8192).refine(value => !value || /^(https?:\/\/|\/(?!\/))[^\s\\]*$/i.test(value), "Invalid media URL");
|
|
27
|
+
const nodeFields = {
|
|
28
|
+
id: zod_1.z.number().int().nonnegative(), title: label.optional(), visual_step: label.optional(),
|
|
29
|
+
description: rich.optional(), steps: rich.optional(), expected_result: rich.optional(), references: rich.optional(),
|
|
30
|
+
defect_report: rich.optional(), defect_media: media.optional(), assigned_to: label.optional(), priority: label.optional(),
|
|
31
|
+
x: numeric.optional(), y: numeric.optional(), width: numeric.optional(), height: numeric.optional(), is_active: zod_1.z.boolean().optional(),
|
|
32
|
+
};
|
|
33
|
+
const node = zod_1.z.looseObject(nodeFields);
|
|
34
|
+
const step = zod_1.z.looseObject({ ...nodeFields, status: zod_1.z.enum(["Pending", "Current", "Passed", "Failed", "Blocked", "Skipped", "Pass", "Fail", "Success", "passed", "failed", "pass", "fail", "success", "pending", "skipped", "blocked"]), group_title: label.optional() });
|
|
35
|
+
const group = zod_1.z.looseObject({ id: zod_1.z.number().int().nonnegative(), title: label.optional(), x: numeric.optional(), y: numeric.optional(), width: numeric.optional(), height: numeric.optional(), is_active: zod_1.z.boolean().optional() });
|
|
36
|
+
const text = zod_1.z.looseObject({ id: zod_1.z.number().int().nonnegative(), content: label.optional(), x: numeric.optional(), y: numeric.optional(), width: numeric.optional(), height: numeric.optional(), is_active: zod_1.z.boolean().optional() });
|
|
37
|
+
const connection = zod_1.z.looseObject({ id: zod_1.z.number().int().nonnegative() });
|
|
38
|
+
exports.manualRunSchema = zod_1.z.object({
|
|
39
|
+
name: zod_1.z.string().trim().min(1).max(255),
|
|
40
|
+
status: zod_1.z.enum(["PASS", "FAIL", "INCOMPLETE", "NO STEPS"]),
|
|
41
|
+
metrics: zod_1.z.object({ total: zod_1.z.number().int().nonnegative(), passed: zod_1.z.number().int().nonnegative(), failed: zod_1.z.number().int().nonnegative(), pending: zod_1.z.number().int().nonnegative() }).refine(m => m.total === m.passed + m.failed + m.pending, "Inconsistent metrics"),
|
|
42
|
+
canvasData: zod_1.z.looseObject({ nodes: zod_1.z.array(node).max(10000).default([]), groups: zod_1.z.array(group).max(10000).default([]), texts: zod_1.z.array(text).max(10000).default([]), connections: zod_1.z.array(connection).max(20000).default([]), executionReport: zod_1.z.array(step).max(10000).default([]), metadata: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional() }),
|
|
43
|
+
});
|
|
44
|
+
// Sanitize older records without trusting their original ingestion path.
|
|
45
|
+
function sanitizeStoredCanvas(value) {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
47
|
+
return {};
|
|
48
|
+
const canvas = { ...value };
|
|
49
|
+
for (const key of ["nodes", "executionReport"]) {
|
|
50
|
+
if (!Array.isArray(canvas[key])) {
|
|
51
|
+
canvas[key] = [];
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
canvas[key] = canvas[key].filter(item => item && typeof item === "object").map(item => {
|
|
55
|
+
const result = { ...item };
|
|
56
|
+
for (const field of ["description", "steps", "expected_result", "references", "defect_report"]) {
|
|
57
|
+
if (field in result)
|
|
58
|
+
result[field] = sanitizeManualHtml(result[field]);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return canvas;
|
|
64
|
+
}
|
|
65
|
+
exports.manualCanvasSchema = exports.manualRunSchema.shape.canvasData;
|
|
66
|
+
class ManualInputError extends Error {
|
|
67
|
+
status;
|
|
68
|
+
constructor(status, message) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.status = status;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.ManualInputError = ManualInputError;
|
|
74
|
+
async function readManualPayload(req) {
|
|
75
|
+
const limit = 2 * 1024 * 1024;
|
|
76
|
+
const length = req.headers["content-length"];
|
|
77
|
+
if (length && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length))))
|
|
78
|
+
throw new ManualInputError(400, "Invalid request length");
|
|
79
|
+
if (length && Number(length) > limit)
|
|
80
|
+
throw new ManualInputError(413, "Manual payload exceeds 2 MiB");
|
|
81
|
+
const buffer = Buffer.allocUnsafe(length ? Number(length) : limit);
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
|
|
84
|
+
bytes += chunk.length;
|
|
85
|
+
if (bytes > limit || (length && bytes > Number(length))) {
|
|
86
|
+
req.resume();
|
|
87
|
+
throw new ManualInputError(413, "Manual payload exceeds allowed size");
|
|
88
|
+
}
|
|
89
|
+
buffer.set(chunk, bytes - chunk.length);
|
|
90
|
+
}
|
|
91
|
+
if (length && bytes !== Number(length))
|
|
92
|
+
throw new ManualInputError(400, "Request length mismatch");
|
|
93
|
+
let payload;
|
|
94
|
+
try {
|
|
95
|
+
payload = JSON.parse(buffer.subarray(0, bytes).toString("utf8"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new ManualInputError(400, "Invalid manual JSON");
|
|
99
|
+
}
|
|
100
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
101
|
+
throw new ManualInputError(400, "Invalid manual payload");
|
|
102
|
+
const canvas = exports.manualCanvasSchema.safeParse(payload.canvasData);
|
|
103
|
+
if (!canvas.success)
|
|
104
|
+
throw new ManualInputError(400, "Invalid manual canvas");
|
|
105
|
+
return { ...payload, canvasData: canvas.data };
|
|
106
|
+
}
|
|
@@ -3,6 +3,7 @@ export declare class DataGenerators {
|
|
|
3
3
|
private runIdContext;
|
|
4
4
|
private workerIdContext;
|
|
5
5
|
constructor(runId?: string, workerId?: string);
|
|
6
|
+
sensitiveValues(): string[];
|
|
6
7
|
resolve(template: string): string;
|
|
7
8
|
private pickRandom;
|
|
8
9
|
uuid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
@@ -19,6 +19,9 @@ class DataGenerators {
|
|
|
19
19
|
this.runIdContext = runId;
|
|
20
20
|
this.workerIdContext = workerId;
|
|
21
21
|
}
|
|
22
|
+
sensitiveValues() {
|
|
23
|
+
return [...this.memoryBank.entries()].filter(([key]) => /password|passcode|token|otp|pin|secret/i.test(key)).map(([, value]) => value);
|
|
24
|
+
}
|
|
22
25
|
resolve(template) {
|
|
23
26
|
if (!template || typeof template !== "string")
|
|
24
27
|
return template;
|
|
@@ -44,6 +44,9 @@ class DOMStripper {
|
|
|
44
44
|
el.removeAttribute("src");
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
|
+
clone.querySelectorAll('input').forEach(input => input.removeAttribute('value'));
|
|
48
|
+
clone.querySelectorAll('textarea').forEach(input => { input.textContent = '[REDACTED]'; });
|
|
49
|
+
clone.querySelectorAll('[contenteditable]').forEach(input => { input.textContent = '[REDACTED]'; });
|
|
47
50
|
let rawHtml = clone.outerHTML;
|
|
48
51
|
rawHtml = rawHtml.replace(/[\n\r\t]+/g, " ");
|
|
49
52
|
rawHtml = rawHtml.replace(/\s{2,}/g, " ");
|
|
@@ -37,6 +37,8 @@ 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 localDashboard_1 = require("../security/localDashboard");
|
|
41
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
40
42
|
const fs = __importStar(require("fs"));
|
|
41
43
|
const path = __importStar(require("path"));
|
|
42
44
|
const http = __importStar(require("http"));
|
|
@@ -76,25 +78,9 @@ class FlowOrchestrator {
|
|
|
76
78
|
}
|
|
77
79
|
async executeTestRun(targetPath) {
|
|
78
80
|
this.forwardToDashboard("SUITE_START", {});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const allFlowFiles = fs
|
|
83
|
-
.readdirSync(targetPath)
|
|
84
|
-
.filter((f) => f.endsWith(".flow"));
|
|
85
|
-
const priorityQueue = allFlowFiles
|
|
86
|
-
.filter((f) => f.endsWith(".first.flow"))
|
|
87
|
-
.sort()
|
|
88
|
-
.map((f) => path.join(targetPath, f));
|
|
89
|
-
const standardQueue = allFlowFiles
|
|
90
|
-
.filter((f) => !f.endsWith(".first.flow"))
|
|
91
|
-
.sort()
|
|
92
|
-
.map((f) => path.join(targetPath, f));
|
|
93
|
-
filesToProcess = [...priorityQueue, ...standardQueue];
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
filesToProcess = [targetPath];
|
|
97
|
-
}
|
|
81
|
+
const filesToProcess = (0, projectPaths_1.discoverFlows)(targetPath);
|
|
82
|
+
if (this.config)
|
|
83
|
+
this.config.flowRoot = fs.statSync(targetPath).isDirectory() ? targetPath : path.dirname(targetPath);
|
|
98
84
|
this.globalTestScenarios = [];
|
|
99
85
|
this.stepStatusRegistry.clear();
|
|
100
86
|
if (this.workerCount > 1) {
|
|
@@ -123,7 +109,12 @@ class FlowOrchestrator {
|
|
|
123
109
|
if (this.config?.ci)
|
|
124
110
|
return await this.executeTestRun(targetPath);
|
|
125
111
|
this.httpServer = http.createServer((req, res) => {
|
|
126
|
-
|
|
112
|
+
if (!(0, localDashboard_1.isLocalDashboardRequest)(req.headers.host, req.headers.origin)) {
|
|
113
|
+
res.writeHead(403);
|
|
114
|
+
res.end('Forbidden');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
127
118
|
if (req.url && req.url.startsWith("/videos/")) {
|
|
128
119
|
let urlObj;
|
|
129
120
|
try {
|
|
@@ -236,7 +227,7 @@ class FlowOrchestrator {
|
|
|
236
227
|
console.error(chalk_1.default.red(`\n[Error]: Failed to start local server: ${e.message}`));
|
|
237
228
|
process.exit(1);
|
|
238
229
|
});
|
|
239
|
-
this.wss = new ws_1.WebSocketServer({ server: this.httpServer });
|
|
230
|
+
this.wss = new ws_1.WebSocketServer({ server: this.httpServer, verifyClient: (info) => Boolean(info.origin) && (0, localDashboard_1.isLocalDashboardRequest)(info.req.headers.host, info.origin) });
|
|
240
231
|
this.wss.on("connection", (ws) => {
|
|
241
232
|
if (this.disconnectTimer) {
|
|
242
233
|
clearTimeout(this.disconnectTimer);
|
|
@@ -292,7 +283,7 @@ class FlowOrchestrator {
|
|
|
292
283
|
});
|
|
293
284
|
});
|
|
294
285
|
return new Promise((resolve, reject) => {
|
|
295
|
-
this.httpServer.listen(0, async () => {
|
|
286
|
+
this.httpServer.listen(0, '127.0.0.1', async () => {
|
|
296
287
|
try {
|
|
297
288
|
const address = this.httpServer?.address();
|
|
298
289
|
const port = typeof address === "object" && address !== null
|
|
@@ -37,6 +37,10 @@ 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 deadline_1 = require("../ci/deadline");
|
|
41
|
+
const replayRequest_1 = require("../utils/replayRequest");
|
|
42
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
43
|
+
const redaction_1 = require("../security/redaction");
|
|
40
44
|
const fs = __importStar(require("fs"));
|
|
41
45
|
const path = __importStar(require("path"));
|
|
42
46
|
const chalk_1 = __importDefault(require("chalk"));
|
|
@@ -55,6 +59,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
55
59
|
pluginManager;
|
|
56
60
|
isSilent = process.env.FLOWSTRIDE_PARALLEL === "true";
|
|
57
61
|
isAborted = false;
|
|
62
|
+
bailTriggered = false;
|
|
58
63
|
isDebugHalted = false;
|
|
59
64
|
currentStepContext = "Initializing...";
|
|
60
65
|
currentFileName = "System";
|
|
@@ -76,7 +81,8 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
76
81
|
this.pluginManager = pluginManager;
|
|
77
82
|
}
|
|
78
83
|
broadcast(type, payload) {
|
|
79
|
-
this.
|
|
84
|
+
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
|
+
this.emit('worker_event', { type, payload: (0, redaction_1.redactWorkerPayload)(type, payload, secrets, this.config.ci === false) });
|
|
80
86
|
}
|
|
81
87
|
emitLog(type, status, summary, details, customId, overrideFileName) {
|
|
82
88
|
const id = customId || Math.random().toString(36).substr(2, 9);
|
|
@@ -278,6 +284,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
278
284
|
url: cleanUrl,
|
|
279
285
|
requestHeaders: formattedHeaders,
|
|
280
286
|
requestBody: parsedReqBody,
|
|
287
|
+
rawReplayRequest: { method: method.toUpperCase(), url: cleanUrl, headers: formattedHeaders, body: rawBodyString },
|
|
281
288
|
responseStatus: state.status,
|
|
282
289
|
responseHeaders: state.headers,
|
|
283
290
|
responseBody: state.body,
|
|
@@ -300,33 +307,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
300
307
|
});
|
|
301
308
|
}
|
|
302
309
|
async handleReplayRequest(payload) {
|
|
310
|
+
const requestId = typeof payload?.requestId === 'string' ? payload.requestId : undefined;
|
|
303
311
|
try {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (payload.headers)
|
|
308
|
-
parsedHeaders = JSON.parse(payload.headers);
|
|
309
|
-
}
|
|
310
|
-
catch (e) { }
|
|
311
|
-
try {
|
|
312
|
-
if (payload.body)
|
|
313
|
-
parsedBody = JSON.parse(payload.body);
|
|
314
|
-
}
|
|
315
|
-
catch (e) {
|
|
316
|
-
parsedBody = payload.body;
|
|
317
|
-
}
|
|
318
|
-
const fetchOptions = {
|
|
319
|
-
method: payload.method,
|
|
320
|
-
headers: parsedHeaders,
|
|
321
|
-
};
|
|
322
|
-
if (parsedBody && payload.method !== "GET" && payload.method !== "HEAD") {
|
|
323
|
-
fetchOptions.body =
|
|
324
|
-
typeof parsedBody === "string"
|
|
325
|
-
? parsedBody
|
|
326
|
-
: JSON.stringify(parsedBody);
|
|
327
|
-
}
|
|
328
|
-
const response = await fetch(payload.url, fetchOptions);
|
|
329
|
-
const status = response.status;
|
|
312
|
+
const { url, options } = (0, replayRequest_1.buildReplayRequest)(payload);
|
|
313
|
+
options.signal = AbortSignal.timeout(this.config.timeout || 30000);
|
|
314
|
+
const response = await fetch(url, options);
|
|
330
315
|
const text = await response.text();
|
|
331
316
|
let responseBody;
|
|
332
317
|
try {
|
|
@@ -335,17 +320,18 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
335
320
|
catch {
|
|
336
321
|
responseBody = text;
|
|
337
322
|
}
|
|
338
|
-
this.broadcast(
|
|
339
|
-
status,
|
|
340
|
-
responseBody,
|
|
323
|
+
this.broadcast('REPLAY_RES', {
|
|
324
|
+
status: response.status, responseBody, error: null, requestId,
|
|
341
325
|
fileName: this.currentFileName,
|
|
342
326
|
});
|
|
343
327
|
}
|
|
344
328
|
catch (error) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
329
|
+
const code = error instanceof replayRequest_1.ReplayRequestError ? 'REPLAY_INVALID_REQUEST'
|
|
330
|
+
: error?.name === 'TimeoutError' ? 'REPLAY_TIMEOUT' : 'REPLAY_NETWORK_ERROR';
|
|
331
|
+
const message = (0, redaction_1.redactText)(String(error?.message || 'Replay request failed'), [this.config.machineToken || '']);
|
|
332
|
+
this.broadcast('REPLAY_RES', {
|
|
333
|
+
status: null, error: code, requestId,
|
|
334
|
+
responseBody: { error: message, code }, fileName: this.currentFileName,
|
|
349
335
|
});
|
|
350
336
|
}
|
|
351
337
|
}
|
|
@@ -362,6 +348,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
362
348
|
}
|
|
363
349
|
this.activeAudioPath = undefined;
|
|
364
350
|
this.isAborted = false;
|
|
351
|
+
this.bailTriggered = false;
|
|
365
352
|
this.isDebugHalted = false;
|
|
366
353
|
const sigintHandler = async () => {
|
|
367
354
|
if (this.config.ci) {
|
|
@@ -387,7 +374,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
387
374
|
const audioMatch = content.match(/(?:flow\.)?injectAudio\s+["']([^"']+)["']/i);
|
|
388
375
|
if (audioMatch && audioMatch[1]) {
|
|
389
376
|
const rawPath = audioMatch[1];
|
|
390
|
-
const absolutePath =
|
|
377
|
+
const absolutePath = (0, projectPaths_1.projectPath)(process.cwd(), rawPath);
|
|
391
378
|
if (!fs.existsSync(absolutePath)) {
|
|
392
379
|
throw new Error(`Pre-launch initialization failed: Audio file not found at ${absolutePath}`);
|
|
393
380
|
}
|
|
@@ -396,11 +383,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
396
383
|
}
|
|
397
384
|
}
|
|
398
385
|
for (const filePath of filesToProcess) {
|
|
399
|
-
if (this.isAborted || this.isDebugHalted)
|
|
386
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
400
387
|
break;
|
|
401
388
|
const fileStartTime = Date.now();
|
|
402
389
|
try {
|
|
403
|
-
const fileName =
|
|
390
|
+
const fileName = (0, projectPaths_1.flowIdentity)(filePath, this.config.flowRoot);
|
|
404
391
|
this.currentFileName = fileName;
|
|
405
392
|
this.config.testFile = fileName;
|
|
406
393
|
this.localVariables = {};
|
|
@@ -451,14 +438,14 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
451
438
|
});
|
|
452
439
|
}
|
|
453
440
|
for (const f of features) {
|
|
454
|
-
if (this.isAborted || this.isDebugHalted)
|
|
441
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
455
442
|
break;
|
|
456
443
|
if (f.scenarios.length === 0)
|
|
457
444
|
continue;
|
|
458
445
|
if (!this.isSilent)
|
|
459
446
|
console.log(chalk_1.default.magenta.bold(` Feature: ${f.name}`));
|
|
460
447
|
for (const s of f.scenarios) {
|
|
461
|
-
if (this.isAborted || this.isDebugHalted)
|
|
448
|
+
if (this.isAborted || this.isDebugHalted || this.bailTriggered)
|
|
462
449
|
break;
|
|
463
450
|
if (!this.isSilent)
|
|
464
451
|
console.log(chalk_1.default.cyan(` Scenario: ${s.name}`));
|
|
@@ -467,7 +454,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
467
454
|
fileName: this.currentFileName,
|
|
468
455
|
});
|
|
469
456
|
await this.runScenario(s, f.declarations);
|
|
470
|
-
if (this.isDebugHalted)
|
|
457
|
+
if (this.isDebugHalted || this.bailTriggered)
|
|
471
458
|
break;
|
|
472
459
|
await this.web
|
|
473
460
|
.getPage()
|
|
@@ -488,7 +475,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
488
475
|
if (!this.config.ci)
|
|
489
476
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
490
477
|
try {
|
|
491
|
-
const baseName = this.currentFileName.replace(
|
|
478
|
+
const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
|
|
492
479
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
493
480
|
if (!fs.existsSync(mockCloudDir))
|
|
494
481
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
@@ -511,7 +498,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
511
498
|
if (!this.isSilent)
|
|
512
499
|
console.error(chalk_1.default.red(`\n[Execution]: Failed processing ${this.currentFileName}: ${e.message}`));
|
|
513
500
|
try {
|
|
514
|
-
const baseName = this.currentFileName.replace(
|
|
501
|
+
const baseName = (0, projectPaths_1.videoFileName)(this.currentFileName).replace(/\.webm$/, '');
|
|
515
502
|
const mockCloudDir = path.join(process.cwd(), ".flowstride", "mock-cloud");
|
|
516
503
|
if (!fs.existsSync(mockCloudDir))
|
|
517
504
|
fs.mkdirSync(mockCloudDir, { recursive: true });
|
|
@@ -544,7 +531,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
544
531
|
try {
|
|
545
532
|
const directory = path.join(process.cwd(), '.flowstride', 'artifacts');
|
|
546
533
|
fs.mkdirSync(directory, { recursive: true });
|
|
547
|
-
await this.web.shutdown(path.join(directory,
|
|
534
|
+
await this.web.shutdown(path.join(directory, (0, projectPaths_1.videoFileName)(this.currentFileName)));
|
|
548
535
|
}
|
|
549
536
|
catch (error) {
|
|
550
537
|
this.broadcast('WORKER_ERROR', { message: error.message });
|
|
@@ -554,6 +541,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
554
541
|
}
|
|
555
542
|
}
|
|
556
543
|
async runScenario(scenario, globalDeclarations = []) {
|
|
544
|
+
let hasScenarioFailed = false;
|
|
557
545
|
for (const step of scenario.steps) {
|
|
558
546
|
if (this.isAborted)
|
|
559
547
|
break;
|
|
@@ -565,6 +553,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
565
553
|
step.payload = this.resolveTemplateData(step.payload);
|
|
566
554
|
this.currentStepContext = `${blockName} ${blockDesc}`;
|
|
567
555
|
const isOpt = step.payload?.optional;
|
|
556
|
+
if (hasScenarioFailed) {
|
|
557
|
+
this.broadcast('STEP_ADD', { id: step.id, type: blockName, description: blockDesc, command: step.action, status: 'not_tested', fileName: this.currentFileName });
|
|
558
|
+
this.broadcast('STEP_UPDATE', { id: step.id, status: 'not_tested', fileName: this.currentFileName });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
568
561
|
const mockSpinner = {
|
|
569
562
|
text: "",
|
|
570
563
|
start() {
|
|
@@ -693,7 +686,11 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
693
686
|
screenshot: stepContext.screenshot,
|
|
694
687
|
fileName: this.currentFileName,
|
|
695
688
|
});
|
|
696
|
-
|
|
689
|
+
hasScenarioFailed = true;
|
|
690
|
+
if (this.config.bail && !this.bailTriggered) {
|
|
691
|
+
this.bailTriggered = true;
|
|
692
|
+
this.broadcast('BAIL', { fileName: this.currentFileName, id: step.id });
|
|
693
|
+
}
|
|
697
694
|
}
|
|
698
695
|
}
|
|
699
696
|
}
|
|
@@ -857,6 +854,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
857
854
|
localStorage: stateToSave.localStorage || {},
|
|
858
855
|
sessionStorage: stateToSave.sessionStorage || {},
|
|
859
856
|
fallbackDomain,
|
|
857
|
+
storageOrigin: stateToSave.storageOrigin,
|
|
860
858
|
};
|
|
861
859
|
if (payload.isGlobal) {
|
|
862
860
|
FlowWorker.globalBrowserSessions[payload.name] = hybridState;
|
|
@@ -905,7 +903,7 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
905
903
|
}
|
|
906
904
|
if (!loadedState)
|
|
907
905
|
throw new Error(`State Error: The session "${payload.name}" does not exist in memory or on disk.`);
|
|
908
|
-
await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain);
|
|
906
|
+
await this.web.injectStorage(loadedState.cookies, loadedState.localStorage, loadedState.fallbackDomain, loadedState.sessionStorage, loadedState.storageOrigin);
|
|
909
907
|
}
|
|
910
908
|
break;
|
|
911
909
|
case "open":
|
|
@@ -1155,6 +1153,27 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1155
1153
|
await this.web.expectText(payload.selector, payload.fuzzy, payload.elementType);
|
|
1156
1154
|
else if (expectType === "value")
|
|
1157
1155
|
await this.web.expectValue(payload.selector, payload.text, payload.elementType);
|
|
1156
|
+
else if (expectType === "attribute") {
|
|
1157
|
+
const deadline = Date.now() + this.config.timeout;
|
|
1158
|
+
let actual = null;
|
|
1159
|
+
const failure = () => `Attribute assertion failed: ${payload.attribute} on ${payload.selector}. Expected ${payload.fuzzy ? 'to contain ' : ''}${JSON.stringify(payload.text)}, received ${JSON.stringify(actual)}.`;
|
|
1160
|
+
while (true) {
|
|
1161
|
+
const remaining = deadline - Date.now();
|
|
1162
|
+
if (remaining <= 0)
|
|
1163
|
+
throw new Error(failure());
|
|
1164
|
+
try {
|
|
1165
|
+
actual = await (0, deadline_1.withDeadline)(this.web.getAttribute(payload.selector, payload.attribute, payload.elementType), remaining);
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
throw new Error(`${failure()}\n${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1169
|
+
}
|
|
1170
|
+
if (actual !== null && (payload.fuzzy ? actual.includes(payload.text) : actual === payload.text))
|
|
1171
|
+
break;
|
|
1172
|
+
if (Date.now() >= deadline)
|
|
1173
|
+
throw new Error(failure());
|
|
1174
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(50, deadline - Date.now()))));
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1158
1177
|
else if (expectType === "transcript")
|
|
1159
1178
|
await this.web.expectTranscript(payload.text);
|
|
1160
1179
|
break;
|
|
@@ -1189,12 +1208,14 @@ class FlowWorker extends events_1.EventEmitter {
|
|
|
1189
1208
|
const sharedId = Math.random().toString(36).substr(2, 9);
|
|
1190
1209
|
const urlObj = new URL(request.url());
|
|
1191
1210
|
const endpointName = urlObj.pathname.split("/").pop() || urlObj.pathname;
|
|
1192
|
-
const
|
|
1211
|
+
const requestHeaders = await request.allHeaders().catch(() => request.headers());
|
|
1212
|
+
const curlString = this.web.synthesizeCurl(request.method(), request.url(), requestHeaders, rawPostData);
|
|
1193
1213
|
this.emitLog("REST", response.status() < 400 ? "success" : "error", `${request.method()} ${endpointName}`, {
|
|
1194
1214
|
method: request.method(),
|
|
1195
1215
|
url: request.url(),
|
|
1196
|
-
requestHeaders:
|
|
1216
|
+
requestHeaders: requestHeaders,
|
|
1197
1217
|
requestBody: requestBody,
|
|
1218
|
+
rawReplayRequest: { method: request.method(), url: request.url(), headers: requestHeaders, body: rawPostData },
|
|
1198
1219
|
responseStatus: response.status(),
|
|
1199
1220
|
responseHeaders: response.headers(),
|
|
1200
1221
|
responseBody: responseBody,
|
|
@@ -34,13 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.PersistentVault = void 0;
|
|
37
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const path = __importStar(require("path"));
|
|
39
40
|
class PersistentVault {
|
|
40
41
|
vaultPath;
|
|
41
42
|
data = {};
|
|
42
43
|
constructor(projectRoot = process.cwd()) {
|
|
43
|
-
this.vaultPath = path.join(projectRoot, ".flowstride", "vault.json");
|
|
44
|
+
this.vaultPath = (0, projectPaths_1.projectPath)(projectRoot, path.join(projectRoot, ".flowstride", "vault.json"));
|
|
44
45
|
this.initialiseVault();
|
|
45
46
|
}
|
|
46
47
|
initialiseVault() {
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.PluginManager = void 0;
|
|
40
|
+
const projectPaths_1 = require("../utils/projectPaths");
|
|
40
41
|
const deadline_1 = require("../ci/deadline");
|
|
41
42
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
43
|
const sdk_1 = require("../game/sdk");
|
|
@@ -82,6 +83,7 @@ class PluginManager {
|
|
|
82
83
|
const pluginsPath = this.config.pluginsDir || path.join(process.cwd(), "plugins");
|
|
83
84
|
fullPath = path.resolve(pluginsPath, `${pluginFile}.ts`);
|
|
84
85
|
}
|
|
86
|
+
fullPath = (0, projectPaths_1.projectPath)(process.cwd(), fullPath);
|
|
85
87
|
try {
|
|
86
88
|
let module;
|
|
87
89
|
if (fullPath.endsWith(".ts")) {
|
|
@@ -110,7 +112,7 @@ class PluginManager {
|
|
|
110
112
|
throw new Error('Attachment name must be a simple filename.');
|
|
111
113
|
const directory = this.config.artifactsDir || path.resolve('flowstride-artifacts');
|
|
112
114
|
node_fs_1.default.mkdirSync(directory, { recursive: true });
|
|
113
|
-
const prefix =
|
|
115
|
+
const prefix = (0, projectPaths_1.artifactPrefix)(this.config.testFile);
|
|
114
116
|
const file = path.join(directory, prefix + name);
|
|
115
117
|
node_fs_1.default.writeFileSync(file, data);
|
|
116
118
|
return file;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isLocalDashboardRequest = isLocalDashboardRequest;
|
|
4
|
+
/** Reject remote hosts and browser origins before exposing local debugger data. */
|
|
5
|
+
function isLocalDashboardRequest(host, origin) {
|
|
6
|
+
if (!host)
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
const target = new URL(`http://${host}`);
|
|
10
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname) || !target.port || target.username || target.password || target.pathname !== '/' || target.search || target.hash)
|
|
11
|
+
return false;
|
|
12
|
+
if (origin !== undefined) {
|
|
13
|
+
const source = new URL(origin);
|
|
14
|
+
if (source.origin !== target.origin || source.pathname !== '/' || source.search || source.hash || source.username || source.password)
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function redactText(input: string, secrets?: string[]): string;
|
|
2
|
+
export declare function redactPayload(value: any, secrets?: string[], depth?: number): any;
|
|
3
|
+
/** Raw network commands belong only to the local interactive debugger. */
|
|
4
|
+
export declare function redactWorkerPayload(type: string, payload: any, secrets: string[], localDebug?: boolean): any;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactText = redactText;
|
|
4
|
+
exports.redactPayload = redactPayload;
|
|
5
|
+
exports.redactWorkerPayload = redactWorkerPayload;
|
|
6
|
+
const sensitive = /password|passcode|secret|token|authorization|cookie|api.?key|master.?key|otp|email|curlString|rawReplayRequest|strippedDom/i;
|
|
7
|
+
function redactText(input, secrets = []) {
|
|
8
|
+
let value = String(input);
|
|
9
|
+
for (const secret of secrets.filter(s => typeof s === 'string' && s.length >= 4).sort((a, b) => b.length - a.length))
|
|
10
|
+
value = value.split(secret).join('[REDACTED]');
|
|
11
|
+
value = value.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]')
|
|
12
|
+
.replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g, '[REDACTED]')
|
|
13
|
+
.replace(/(["']?(?:password|passcode|secret|token|otp|authorization|cookie|api[_-]?key|master[_-]?key)["']?\s*[:=]\s*)(["']?)[^\s,;"'<>}]+\2/gi, '$1[REDACTED]')
|
|
14
|
+
.replace(/([?&](?:ticket|token|signature|x-amz-[^=&#]+)=)[^&#\s]+/gi, '$1[REDACTED]');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function redactPayload(value, secrets = [], depth = 0) {
|
|
18
|
+
if (depth > 12)
|
|
19
|
+
return '[omitted]';
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
if (/^data:image\//.test(value))
|
|
22
|
+
return value;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.stringify(redactPayload(JSON.parse(value), secrets, depth + 1));
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
return redactText(value, secrets);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map(item => redactPayload(item, secrets, depth + 1));
|
|
34
|
+
if (!value || typeof value !== 'object')
|
|
35
|
+
return value;
|
|
36
|
+
if (Buffer.isBuffer(value))
|
|
37
|
+
return '[binary]';
|
|
38
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive.test(key) ? '[REDACTED]' : redactPayload(item, secrets, depth + 1)]));
|
|
39
|
+
}
|
|
40
|
+
/** Raw network commands belong only to the local interactive debugger. */
|
|
41
|
+
function redactWorkerPayload(type, payload, secrets, localDebug = false) {
|
|
42
|
+
const result = redactPayload(payload, secrets);
|
|
43
|
+
if (!localDebug || !payload || !result)
|
|
44
|
+
return result;
|
|
45
|
+
if (type === 'LOG_EVENT' && ['REST', 'GRAPHQL'].includes(payload.type) && typeof payload.details?.curlString === 'string') {
|
|
46
|
+
result.details.rawCurlString = payload.details.curlString;
|
|
47
|
+
if (payload.details.rawReplayRequest)
|
|
48
|
+
result.details.rawReplayRequest = payload.details.rawReplayRequest;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
package/dist/sessions/manager.js
CHANGED
|
@@ -35,7 +35,7 @@ class SessionManager {
|
|
|
35
35
|
data.lastUsedAt = Date.now();
|
|
36
36
|
this.vault.save(`session_${name}`, data);
|
|
37
37
|
this.activeSession = data;
|
|
38
|
-
await this.web.injectStorage(data.cookies, data.localStorage);
|
|
38
|
+
await this.web.injectStorage(data.cookies, data.localStorage, undefined, data.sessionStorage, data.storageOrigin);
|
|
39
39
|
const definition = this.definitions.get(name);
|
|
40
40
|
if (definition?.inject?.api) {
|
|
41
41
|
const { headerName, template } = definition.inject.api;
|
|
@@ -64,6 +64,8 @@ class SessionManager {
|
|
|
64
64
|
tokens: this.activeSession?.tokens || {},
|
|
65
65
|
cookies: state.cookies,
|
|
66
66
|
localStorage: state.localStorage,
|
|
67
|
+
sessionStorage: state.sessionStorage,
|
|
68
|
+
storageOrigin: state.storageOrigin,
|
|
67
69
|
createdAt: Date.now(),
|
|
68
70
|
lastUsedAt: Date.now(),
|
|
69
71
|
persist,
|