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.
Files changed (43) hide show
  1. package/NATIVE_CI.md +88 -145
  2. package/README.md +46 -221
  3. package/dist/adapters/web/playwright.d.ts +4 -1
  4. package/dist/adapters/web/playwright.js +75 -6
  5. package/dist/ci/config.js +6 -5
  6. package/dist/ci/runtime.js +27 -14
  7. package/dist/cli/commands/run.js +2 -1
  8. package/dist/cli/index.js +35 -50
  9. package/dist/cli/manual_server/workspace_server.js +44 -32
  10. package/dist/cli/utils/cloudSession.d.ts +7 -0
  11. package/dist/cli/utils/cloudSession.js +37 -0
  12. package/dist/cli/utils/proInstaller.d.ts +5 -0
  13. package/dist/cli/utils/proInstaller.js +141 -0
  14. package/dist/game/sdk.js +3 -2
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.js +9 -0
  17. package/dist/manual/security.d.ts +183 -0
  18. package/dist/manual/security.js +106 -0
  19. package/dist/runner/data_generators.d.ts +1 -0
  20. package/dist/runner/data_generators.js +3 -0
  21. package/dist/runner/dom_stripper.js +3 -0
  22. package/dist/runner/flow_orchestrator.js +13 -22
  23. package/dist/runner/flow_worker.d.ts +1 -0
  24. package/dist/runner/flow_worker.js +68 -47
  25. package/dist/runner/persistent_vault.js +2 -1
  26. package/dist/runner/plugin_manager.js +3 -1
  27. package/dist/security/localDashboard.d.ts +2 -0
  28. package/dist/security/localDashboard.js +22 -0
  29. package/dist/security/redaction.d.ts +4 -0
  30. package/dist/security/redaction.js +51 -0
  31. package/dist/sessions/manager.js +3 -1
  32. package/dist/types/index.d.ts +9 -0
  33. package/dist/utils/cloud.d.ts +6 -0
  34. package/dist/utils/cloud.js +33 -0
  35. package/dist/utils/projectPaths.d.ts +11 -0
  36. package/dist/utils/projectPaths.js +90 -0
  37. package/dist/utils/replayRequest.d.ts +8 -0
  38. package/dist/utils/replayRequest.js +77 -0
  39. package/package.json +23 -14
  40. package/src/ui/dist/assets/index-B7MuBTWm.js +460 -0
  41. package/src/ui/dist/index.html +1 -1
  42. package/RELEASE_NOTES.md +0 -40
  43. 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
- let filesToProcess = [];
80
- const stats = fs.statSync(targetPath);
81
- if (stats.isDirectory()) {
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
- res.setHeader("Access-Control-Allow-Origin", "*");
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
@@ -21,6 +21,7 @@ export declare class FlowWorker extends EventEmitter {
21
21
  private pluginManager?;
22
22
  private isSilent;
23
23
  private isAborted;
24
+ private bailTriggered;
24
25
  private isDebugHalted;
25
26
  private currentStepContext;
26
27
  private currentFileName;
@@ -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.emit("worker_event", { type, payload });
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
- let parsedHeaders = undefined;
305
- let parsedBody = undefined;
306
- try {
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("REPLAY_RES", {
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
- this.broadcast("REPLAY_RES", {
346
- status: 500,
347
- responseBody: { error: error.message },
348
- fileName: this.currentFileName,
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 = path.resolve(process.cwd(), rawPath);
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 = path.basename(filePath);
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(".flow", "");
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(".flow", "");
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, this.currentFileName.replace(/\.flow$/, '') + '.webm'));
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
- throw e;
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 curlString = this.web.synthesizeCurl(request.method(), request.url(), request.headers(), rawPostData);
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: request.headers(),
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 = this.config.testFile ? this.config.testFile.replace(/[^a-zA-Z0-9_.-]/g, '_') + '-' : '';
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,2 @@
1
+ /** Reject remote hosts and browser origins before exposing local debugger data. */
2
+ export declare function isLocalDashboardRequest(host?: string, origin?: string): boolean;
@@ -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
+ }
@@ -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,