pi-ui-extend 1.0.23 → 1.0.28

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 (44) hide show
  1. package/README.md +5 -1
  2. package/dist/app/app.d.ts +1 -0
  3. package/dist/app/app.js +12 -0
  4. package/dist/app/constants.d.ts +1 -1
  5. package/dist/app/constants.js +1 -1
  6. package/dist/app/icons.d.ts +1 -0
  7. package/dist/app/icons.js +2 -0
  8. package/dist/app/input/input-controller.d.ts +2 -0
  9. package/dist/app/input/input-controller.js +8 -5
  10. package/dist/app/rendering/render-controller.js +2 -0
  11. package/dist/app/rendering/status-line-renderer.d.ts +4 -1
  12. package/dist/app/rendering/status-line-renderer.js +12 -0
  13. package/dist/app/runtime.d.ts +1 -8
  14. package/dist/app/runtime.js +0 -39
  15. package/dist/app/screen/mouse-controller.d.ts +4 -1
  16. package/dist/app/screen/mouse-controller.js +9 -0
  17. package/dist/app/session/agent-pause-controller.d.ts +30 -0
  18. package/dist/app/session/agent-pause-controller.js +178 -0
  19. package/dist/app/session/session-event-controller.d.ts +1 -0
  20. package/dist/app/session/session-event-controller.js +22 -15
  21. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  22. package/dist/app/session/session-lifecycle-controller.js +1 -0
  23. package/dist/app/types.d.ts +10 -0
  24. package/dist/config.js +1 -1
  25. package/dist/default-pix-config.js +2 -2
  26. package/external/pi-tools-suite/README.md +1 -1
  27. package/external/pi-tools-suite/docs/browser-qa-subagent.md +34 -2
  28. package/external/pi-tools-suite/package.json +3 -3
  29. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +24 -18
  30. package/external/pi-tools-suite/src/async-subagents/core/config.ts +14 -3
  31. package/external/pi-tools-suite/src/async-subagents/core/process.ts +19 -0
  32. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +97 -17
  33. package/external/pi-tools-suite/src/async-subagents/core/stop.ts +4 -2
  34. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +108 -13
  35. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +80 -0
  36. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-flow.example.jsonc +54 -1
  37. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +952 -76
  38. package/external/pi-tools-suite/src/coding-discipline/index.ts +2 -94
  39. package/external/pi-tools-suite/src/dcp/index.ts +21 -0
  40. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +92 -0
  41. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +21 -16
  42. package/external/pi-tools-suite/src/todo/index.ts +86 -46
  43. package/external/pi-tools-suite/src/todo/todo.ts +1 -1
  44. package/package.json +4 -4
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import fs from "node:fs";
4
- import path from "node:path";
5
5
  import { createRequire } from "node:module";
6
+ import path from "node:path";
6
7
  import { pathToFileURL } from "node:url";
8
+ import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
7
9
  import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
8
10
  import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
9
11
 
@@ -12,8 +14,36 @@ const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
12
14
  const QA_WORKSPACE_RELATIVE = "browser-qa";
13
15
  const EVIDENCE_RELATIVE = "evidence";
14
16
  const FORM_VIDEO_STEP_DELAY_MS = 250;
17
+ const POPUP_VIDEO_SETTLE_MS = 250;
15
18
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
19
+ const EXIT_RUNNER_TIMEOUT = 124;
20
+ const DEFAULT_RUNNER_TIMEOUT_MS = 90_000;
21
+ const MAX_RUNNER_TIMEOUT_MS = 100_000;
22
+ const CLEANUP_TIMEOUT_MS = 5_000;
23
+ const HARD_EXIT_GRACE_MS = 15_000;
24
+ const PROGRESS_LOG_MAX_BYTES = 1024 * 1024;
25
+ const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
26
+ const MIN_VIEWPORT = Object.freeze({ width: 320, height: 240 });
27
+ const MAX_VIEWPORT = Object.freeze({ width: 3840, height: 2160 });
28
+ const MAX_SCROLL_DISTANCE = 1_000_000;
29
+ const ASSERTION_POLL_INTERVAL_MS = 100;
30
+ const MAX_POPUPS = 3;
31
+ const MAX_UPLOAD_FILES = 10;
32
+ const MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
33
+ const MAX_UPLOAD_TOTAL_BYTES = 10 * 1024 * 1024;
34
+ const MAX_FLOW_BYTES = 16 * 1024 * 1024;
35
+ const DEFAULT_DOWNLOAD_MAX_BYTES = 5 * 1024 * 1024;
36
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
37
+ const DEFAULT_ENVIRONMENT = Object.freeze({
38
+ locale: "en-US",
39
+ timezoneId: "UTC",
40
+ colorScheme: "light",
41
+ reducedMotion: "reduce",
42
+ });
43
+ const ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_.:-]{0,127}$/;
16
44
  const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
45
+ const HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
46
+ const DIALOG_TYPES = new Set(["alert", "beforeunload", "confirm", "prompt"]);
17
47
  const AUTH_TEMPLATE = `{
18
48
  // Authenticated browser QA requires at least one project-local profile.
19
49
  // Fill and uncomment a profile below. Keep this file private.
@@ -37,7 +67,7 @@ const AUTH_TEMPLATE = `{
37
67
  }
38
68
  }
39
69
  `;
40
- process.umask(0o077);
70
+ if (isMainThread) process.umask(0o077);
41
71
 
42
72
  class QaStatusError extends Error {
43
73
  constructor(status, reason, exitCode, profileId, details = {}) {
@@ -50,10 +80,22 @@ class QaStatusError extends Error {
50
80
  }
51
81
  }
52
82
 
53
- main().catch((error) => {
83
+ if (!isMainThread && workerData?.operation === "sanitize_trace") {
84
+ if (process.env.BROWSER_QA_TEST_HANG_STAGE === "trace_sanitize") {
85
+ setInterval(() => {}, 1000);
86
+ } else {
87
+ try {
88
+ sanitizeTraceArchive(workerData.tracePath, workerData.secrets);
89
+ parentPort?.postMessage({ success: true });
90
+ } catch (error) {
91
+ parentPort?.postMessage({ success: false, reason: redact(safeReason(error), workerData.secrets) });
92
+ }
93
+ }
94
+ } else main().catch((error) => {
54
95
  const statusError = error instanceof QaStatusError
55
96
  ? error
56
97
  : new QaStatusError("QA_RUN_FAILED", safeReason(error), 1);
98
+ terminateRunnerDescendants();
57
99
  writeStatus({
58
100
  status: statusError.status,
59
101
  profile: statusError.profileId,
@@ -61,7 +103,7 @@ main().catch((error) => {
61
103
  reason: statusError.reason,
62
104
  ...statusError.details,
63
105
  });
64
- process.exitCode = statusError.exitCode;
106
+ process.exit(statusError.exitCode);
65
107
  });
66
108
 
67
109
  async function main() {
@@ -85,20 +127,45 @@ async function main() {
85
127
  const profileId = selected.id;
86
128
  const profile = selected.profile;
87
129
  const secrets = collectSecrets(profile.auth);
130
+ let hardExitTimer;
131
+ let progress;
88
132
  try {
89
133
  const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
90
- await runQa({ cwd, agentDir, args, profileId, profile });
134
+ const workspaceDir = path.join(agentDir, QA_WORKSPACE_RELATIVE);
135
+ const runnerTimeoutMs = parseRunnerTimeout(args.runnerTimeoutMs, profileId);
136
+ const deadline = Date.now() + runnerTimeoutMs;
137
+ progress = createRunnerProgress(workspaceDir);
138
+ progress("runner_started", { timeoutMs: runnerTimeoutMs });
139
+ hardExitTimer = setTimeout(() => {
140
+ const lastStage = progress?.lastStage?.();
141
+ progress?.("hard_timeout", { timeoutMs: runnerTimeoutMs });
142
+ terminateRunnerDescendants();
143
+ writeStatus({
144
+ status: "QA_RUN_FAILED",
145
+ profile: profileId,
146
+ reason: `browser QA runner did not exit within ${runnerTimeoutMs + HARD_EXIT_GRACE_MS} ms${lastStage ? `; last stage: ${lastStage}` : ""}`,
147
+ timedOut: true,
148
+ ...(lastStage ? { lastStage } : {}),
149
+ });
150
+ process.exit(EXIT_RUNNER_TIMEOUT);
151
+ }, runnerTimeoutMs + HARD_EXIT_GRACE_MS);
152
+ await runQa({ cwd, agentDir, args, profileId, profile, deadline, progress });
91
153
  } catch (error) {
92
154
  if (error instanceof QaStatusError) throw error;
93
155
  throw new QaStatusError("QA_RUN_FAILED", redact(safeReason(error), secrets), 1, profileId);
156
+ } finally {
157
+ if (hardExitTimer) clearTimeout(hardExitTimer);
158
+ progress?.("runner_finished");
94
159
  }
95
160
  }
96
161
 
97
- async function runQa({ cwd, agentDir, args, profileId, profile }) {
162
+ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progress }) {
98
163
  if (!args.flow) throw new QaStatusError("QA_RUN_FAILED", "--flow is required", 1, profileId);
99
164
  const workspaceDir = path.join(agentDir, QA_WORKSPACE_RELATIVE);
100
165
  const flowPath = resolveExistingPrivateFile(workspaceDir, args.flow, "QA flow", false);
101
166
  const flow = readFlow(flowPath, profileId);
167
+ const viewport = resolveViewport(flow, profileId);
168
+ const environment = resolveEnvironment(flow, profileId);
102
169
  const allowedOrigins = normalizeAllowedOrigins(profile.allowedOrigins, profileId);
103
170
  const baseURL = normalizeBaseUrl(args.baseUrl ?? profile.baseUrl ?? allowedOrigins[0], allowedOrigins, profileId);
104
171
  validateAuthConfiguration(cwd, profile.auth, allowedOrigins, profileId);
@@ -106,50 +173,62 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
106
173
  const evidenceDir = path.join(workspaceDir, EVIDENCE_RELATIVE, runId, profileId);
107
174
  createExclusivePrivateDirectory(agentDir, evidenceDir);
108
175
 
176
+ progress("playwright_load_started");
109
177
  const playwright = loadPlaywright(cwd);
110
- const browser = await playwright.chromium.launch({ headless: true });
178
+ progress("playwright_load_finished");
179
+ const browser = await runStage(progress, "browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
111
180
  let context;
112
181
  let page;
113
- let video;
182
+ const videos = [];
114
183
  let outcome = "passed";
115
184
  let failure;
185
+ const observations = [];
116
186
  const runtimeSecrets = collectSecrets(profile.auth);
117
187
  const tracePath = path.join(evidenceDir, "trace.zip");
118
188
  try {
119
- const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins });
120
- context = await browser.newContext({
189
+ const contextOptions = await runStage(progress, "auth_context_options", deadline, () => contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins }), profileId);
190
+ context = await runStage(progress, "context_create", deadline, () => browser.newContext({
121
191
  ...contextOptions,
122
192
  baseURL,
193
+ ...environment,
123
194
  serviceWorkers: "block",
124
- recordVideo: { dir: evidenceDir, size: { width: 1280, height: 720 } },
125
- viewport: { width: 1280, height: 720 },
126
- });
127
- await installOriginGuard(context, allowedOrigins, profile.auth);
128
- await applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId);
129
- page = await context.newPage();
130
- video = page.video();
131
- await applyFormAuth(page, profile.auth, allowedOrigins, profileId);
132
- runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
133
- await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
134
- await executeFlow({ page, context, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets });
135
- await assertPageDoesNotExposeSecrets(page, runtimeSecrets, profileId);
136
- await page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true });
195
+ recordVideo: { dir: evidenceDir, size: viewport },
196
+ viewport,
197
+ }), profileId);
198
+ await runStage(progress, "origin_guard_install", deadline, () => installOriginGuard(context, allowedOrigins, profile.auth), profileId);
199
+ await runStage(progress, "context_auth_apply", deadline, () => applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId), profileId);
200
+ page = await runStage(progress, "page_create", deadline, () => context.newPage(), profileId);
201
+ videos.push({ name: "video.webm", video: page.video() });
202
+ await runStage(progress, "form_auth_apply", deadline, () => applyFormAuth(page, profile.auth, allowedOrigins, profileId), profileId);
203
+ const initialStorageState = await runStage(progress, "storage_state_collect", deadline, () => context.storageState(), profileId);
204
+ runtimeSecrets.push(...collectStorageStateSecrets(initialStorageState));
205
+ await runStage(progress, "trace_start", deadline, () => context.tracing.start({ screenshots: true, snapshots: true, sources: false }), profileId);
206
+ await runStage(progress, "flow_execute", deadline, () => executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets, observations, videos }), profileId);
207
+ await runStage(progress, "secret_exposure_check", deadline, () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId), profileId);
208
+ await runStage(progress, "final_screenshot", deadline, () => page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true }), profileId);
137
209
  } catch (error) {
138
210
  outcome = error instanceof QaStatusError ? error.status : "failed";
139
211
  failure = error;
140
212
  if (page && !page.isClosed() && !error.suppressEvidence) {
141
213
  try {
142
- await page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true });
214
+ await runCleanupStage(progress, "failure_screenshot", () => page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true }));
143
215
  } catch { /* best effort */ }
144
216
  }
145
217
  } finally {
218
+ const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
146
219
  if (context) {
147
220
  try {
148
- runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
221
+ const finalStorageState = await runCleanupStage(progress, "storage_state_finalize", () => context.storageState(), cleanupDeadline);
222
+ runtimeSecrets.push(...collectStorageStateSecrets(finalStorageState));
149
223
  } catch { /* best effort */ }
150
224
  try {
151
- await context.tracing.stop({ path: tracePath });
152
- sanitizeTraceArchive(tracePath, runtimeSecrets);
225
+ await runCleanupStage(progress, "trace_stop", () => context.tracing.stop({ path: tracePath }), cleanupDeadline);
226
+ await runCleanupStage(
227
+ progress,
228
+ "trace_sanitize",
229
+ () => sanitizeTraceArchiveInWorker(tracePath, runtimeSecrets, cleanupDeadline),
230
+ cleanupDeadline,
231
+ );
153
232
  } catch (error) {
154
233
  fs.rmSync(tracePath, { force: true });
155
234
  if (!failure) {
@@ -157,15 +236,26 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
157
236
  failure = new Error(`trace evidence could not be sanitized: ${safeReason(error)}`);
158
237
  }
159
238
  }
160
- await context.close().catch(() => {});
239
+ await runCleanupStage(progress, "context_close", () => context.close(), cleanupDeadline).catch(() => {});
161
240
  }
162
- if (video) {
241
+ for (const [index, entry] of videos.entries()) {
242
+ if (!entry.video) continue;
163
243
  try {
164
- const generatedVideo = await video.path();
165
- if (fs.existsSync(generatedVideo)) fs.renameSync(generatedVideo, path.join(evidenceDir, "video.webm"));
244
+ const generatedVideo = await runCleanupStage(progress, `video_${index + 1}_finalize`, () => entry.video.path(), cleanupDeadline);
245
+ if (!fs.existsSync(generatedVideo)) continue;
246
+ if (entry.discard) fs.rmSync(generatedVideo, { force: true });
247
+ else fs.renameSync(generatedVideo, path.join(evidenceDir, entry.name));
166
248
  } catch { /* best effort */ }
167
249
  }
168
- await browser.close().catch(() => {});
250
+ await runCleanupStage(progress, "browser_close", () => browser.close(), cleanupDeadline).catch(() => {});
251
+ }
252
+ try {
253
+ for (const name of fs.readdirSync(evidenceDir).filter((name) => /^download-[A-Za-z0-9._-]+\.bin$/.test(name))) {
254
+ assertFileDoesNotExposeSecrets(path.join(evidenceDir, name), runtimeSecrets, profileId);
255
+ }
256
+ } catch (error) {
257
+ outcome = error instanceof QaStatusError ? error.status : "failed";
258
+ failure = error;
169
259
  }
170
260
  if (failure?.suppressEvidence) removeVisualEvidence(evidenceDir);
171
261
 
@@ -174,6 +264,9 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
174
264
  evidenceDir: relativePath(cwd, evidenceDir),
175
265
  evidence,
176
266
  artifacts: artifactManifest(evidenceDir, evidence),
267
+ viewport,
268
+ environment,
269
+ ...(observations.length > 0 ? { observations } : {}),
177
270
  };
178
271
  writeJsonPrivate(path.join(evidenceDir, "result.json"), {
179
272
  status: outcome,
@@ -354,7 +447,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
354
447
 
355
448
  function readFlow(flowPath, profileId) {
356
449
  const stat = fs.statSync(flowPath);
357
- if (!stat.isFile() || stat.size > 256 * 1024) throw new QaStatusError("QA_RUN_FAILED", "QA flow must be a regular JSONC file no larger than 256 KiB", 1, profileId);
450
+ if (!stat.isFile() || stat.size > MAX_FLOW_BYTES) throw new QaStatusError("QA_RUN_FAILED", "QA flow must be a regular JSONC file no larger than 16 MiB", 1, profileId);
358
451
  const errors = [];
359
452
  const flow = parseJsonc(fs.readFileSync(flowPath, "utf8"), errors, { allowTrailingComma: true });
360
453
  if (errors.length > 0 || !isObject(flow) || !Array.isArray(flow.steps) || flow.steps.length === 0 || flow.steps.length > 100) {
@@ -363,26 +456,109 @@ function readFlow(flowPath, profileId) {
363
456
  return flow;
364
457
  }
365
458
 
366
- async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets }) {
459
+ function resolveViewport(flow, profileId) {
460
+ if (flow.viewport === undefined) return { ...DEFAULT_VIEWPORT };
461
+ if (!isObject(flow.viewport)) throw new QaStatusError("QA_RUN_FAILED", "QA flow viewport must be an object with width and height", 1, profileId);
462
+ const { width, height } = flow.viewport;
463
+ if (!Number.isInteger(width) || !Number.isInteger(height)
464
+ || width < MIN_VIEWPORT.width || height < MIN_VIEWPORT.height
465
+ || width > MAX_VIEWPORT.width || height > MAX_VIEWPORT.height) {
466
+ throw new QaStatusError(
467
+ "QA_RUN_FAILED",
468
+ `QA flow viewport must use integer dimensions from ${MIN_VIEWPORT.width}x${MIN_VIEWPORT.height} through ${MAX_VIEWPORT.width}x${MAX_VIEWPORT.height}`,
469
+ 1,
470
+ profileId,
471
+ );
472
+ }
473
+ return { width, height };
474
+ }
475
+
476
+ function resolveEnvironment(flow, profileId) {
477
+ if (flow.environment === undefined) return { ...DEFAULT_ENVIRONMENT };
478
+ if (!isObject(flow.environment)) throw new QaStatusError("QA_RUN_FAILED", "QA flow environment must be an object", 1, profileId);
479
+ const environment = { ...DEFAULT_ENVIRONMENT };
480
+ if (flow.environment.locale !== undefined) {
481
+ if (typeof flow.environment.locale !== "string" || flow.environment.locale.length > 64) {
482
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment locale is invalid", 1, profileId);
483
+ }
484
+ try {
485
+ environment.locale = Intl.getCanonicalLocales(flow.environment.locale)[0];
486
+ } catch {
487
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment locale is invalid", 1, profileId);
488
+ }
489
+ }
490
+ if (flow.environment.timezoneId !== undefined) {
491
+ if (typeof flow.environment.timezoneId !== "string" || flow.environment.timezoneId.length > 100) {
492
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment timezoneId is invalid", 1, profileId);
493
+ }
494
+ try {
495
+ new Intl.DateTimeFormat("en", { timeZone: flow.environment.timezoneId }).format(0);
496
+ environment.timezoneId = flow.environment.timezoneId;
497
+ } catch {
498
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment timezoneId is invalid", 1, profileId);
499
+ }
500
+ }
501
+ if (flow.environment.colorScheme !== undefined) {
502
+ if (!["light", "dark", "no-preference"].includes(flow.environment.colorScheme)) {
503
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment colorScheme is invalid", 1, profileId);
504
+ }
505
+ environment.colorScheme = flow.environment.colorScheme;
506
+ }
507
+ if (flow.environment.reducedMotion !== undefined) {
508
+ if (!["reduce", "no-preference"].includes(flow.environment.reducedMotion)) {
509
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment reducedMotion is invalid", 1, profileId);
510
+ }
511
+ environment.reducedMotion = flow.environment.reducedMotion;
512
+ }
513
+ return environment;
514
+ }
515
+
516
+ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets, observations, videos }) {
367
517
  const timeout = Math.min(finitePositive(flow.timeoutMs) ?? 15_000, 60_000);
368
518
  page.setDefaultTimeout(timeout);
369
519
  page.setDefaultNavigationTimeout(timeout);
520
+ const popups = new Map();
521
+ let activeIndex = 0;
522
+ let popupCaptureArmed = false;
523
+ let capturedPopup;
524
+ let unexpectedPopupError;
525
+ const onContextPage = (candidate) => {
526
+ if (candidate === page) return;
527
+ if (popupCaptureArmed && !capturedPopup) {
528
+ capturedPopup = candidate;
529
+ return;
530
+ }
531
+ videos.push({ name: `discarded-popup-${videos.length}.webm`, video: candidate.video(), discard: true });
532
+ unexpectedPopupError ??= flowError(profileId, activeIndex, "unexpected popup opened; use openPopup to declare it");
533
+ void candidate.close().catch(() => {});
534
+ };
535
+ context.on("page", onContextPage);
536
+ try {
370
537
  for (let index = 0; index < flow.steps.length; index += 1) {
538
+ activeIndex = index;
371
539
  const step = flow.steps[index];
372
540
  if (!isObject(step) || typeof step.action !== "string") throw flowError(profileId, index, "action is missing");
373
- const locator = step.locator === undefined ? undefined : resolveLocator(page, step.locator, profileId, index);
541
+ if ((step.expectResponse !== undefined || step.expectDialog !== undefined)
542
+ && !["click", "doubleClick", "press", "check", "uncheck", "selectOption"].includes(step.action)) {
543
+ throw flowError(profileId, index, "response and dialog expectations require a triggering interaction");
544
+ }
545
+ const { scope, ownerPage } = await resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index });
546
+ const locator = step.locator === undefined ? undefined : resolveLocator(scope, step.locator, profileId, index);
374
547
  switch (step.action) {
375
548
  case "goto": {
376
549
  const target = typeof step.url === "string" ? step.url : step.path;
377
550
  if (typeof target !== "string") throw flowError(profileId, index, "goto requires url or path");
378
551
  const url = new URL(target, baseURL);
379
552
  if (url.username || url.password || !allowedOrigins.includes(url.origin)) throw flowError(profileId, index, "goto target is outside allowedOrigins");
380
- await page.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
553
+ await scope.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
381
554
  break;
382
555
  }
383
- case "reload": await page.reload({ waitUntil: validWaitUntil(step.waitUntil) }); break;
384
- case "click": await requireLocator(locator, profileId, index).click(); break;
385
- case "doubleClick": await requireLocator(locator, profileId, index).dblclick(); break;
556
+ case "reload":
557
+ if (scope !== ownerPage) throw flowError(profileId, index, "reload is not supported for frame targets");
558
+ await ownerPage.reload({ waitUntil: validWaitUntil(step.waitUntil) });
559
+ break;
560
+ case "click": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).click() }); break;
561
+ case "doubleClick": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).dblclick() }); break;
386
562
  case "hover": await requireLocator(locator, profileId, index).hover(); break;
387
563
  case "fill":
388
564
  if (typeof step.value !== "string") throw flowError(profileId, index, "fill requires a string value");
@@ -390,42 +566,176 @@ async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, p
390
566
  break;
391
567
  case "press":
392
568
  if (typeof step.key !== "string" || step.key.length > 80) throw flowError(profileId, index, "press requires a valid key");
393
- await requireLocator(locator, profileId, index).press(step.key);
569
+ await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).press(step.key) });
394
570
  break;
395
- case "check": await requireLocator(locator, profileId, index).check(); break;
396
- case "uncheck": await requireLocator(locator, profileId, index).uncheck(); break;
571
+ case "check": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).check() }); break;
572
+ case "uncheck": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).uncheck() }); break;
397
573
  case "selectOption":
398
574
  if (typeof step.value !== "string" && !isStringArray(step.value)) throw flowError(profileId, index, "selectOption requires a string or string array");
399
- await requireLocator(locator, profileId, index).selectOption(step.value);
575
+ await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).selectOption(step.value) });
576
+ break;
577
+ case "dragTo": {
578
+ const destination = resolveLocator(scope, step.dropTarget, profileId, index);
579
+ const options = {};
580
+ if (step.sourcePosition !== undefined) options.sourcePosition = resolvePoint(step.sourcePosition, profileId, index, "sourcePosition");
581
+ if (step.dropPosition !== undefined) options.targetPosition = resolvePoint(step.dropPosition, profileId, index, "dropPosition");
582
+ await requireLocator(locator, profileId, index).dragTo(destination, options);
583
+ break;
584
+ }
585
+ case "uploadFiles": {
586
+ const files = decodeUploadFiles(step.files, profileId, index);
587
+ await requireLocator(locator, profileId, index).setInputFiles(files);
588
+ break;
589
+ }
590
+ case "openPopup": {
591
+ if (!isSafeName(step.name)) throw flowError(profileId, index, "openPopup requires a safe name");
592
+ if (popups.has(step.name)) throw flowError(profileId, index, `popup name is duplicated: ${step.name}`);
593
+ if (popups.size >= MAX_POPUPS) throw flowError(profileId, index, `openPopup supports at most ${MAX_POPUPS} popups`);
594
+ popupCaptureArmed = true;
595
+ capturedPopup = undefined;
596
+ const popupPromise = ownerPage.waitForEvent("popup", { timeout });
597
+ let popup;
598
+ let popupVideo;
599
+ try {
600
+ [, popup] = await Promise.all([requireLocator(locator, profileId, index).click(), popupPromise]);
601
+ popup.setDefaultTimeout(timeout);
602
+ popup.setDefaultNavigationTimeout(timeout);
603
+ popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: false };
604
+ videos.push(popupVideo);
605
+ await popup.waitForLoadState("domcontentloaded", { timeout });
606
+ const origin = assertSameOriginPage(popup, ownerPage, allowedOrigins, profileId, index, "popup");
607
+ await popup.bringToFront();
608
+ // A static popup may not repaint after Playwright starts its screencast, leaving the video blank.
609
+ await popup.screenshot({ type: "png" });
610
+ await popup.waitForTimeout(POPUP_VIDEO_SETTLE_MS);
611
+ popups.set(step.name, { page: popup, origin });
612
+ } catch (error) {
613
+ popup ??= capturedPopup;
614
+ if (popup && !popupVideo) {
615
+ popupVideo = { name: `discarded-popup-${videos.length}.webm`, video: popup.video(), discard: true };
616
+ videos.push(popupVideo);
617
+ }
618
+ if (popupVideo) popupVideo.discard = true;
619
+ await popup?.close().catch(() => {});
620
+ throw error;
621
+ } finally {
622
+ popupCaptureArmed = false;
623
+ capturedPopup = undefined;
624
+ }
625
+ break;
626
+ }
627
+ case "download": {
628
+ await executeDownload({ ownerPage, locator: requireLocator(locator, profileId, index), step, evidenceDir, allowedOrigins, timeout, profileId, index, secrets });
629
+ break;
630
+ }
631
+ case "wheel": {
632
+ const deltaX = optionalFiniteNumber(step.deltaX, 0, profileId, index, "wheel deltaX");
633
+ const deltaY = optionalFiniteNumber(step.deltaY, 0, profileId, index, "wheel deltaY");
634
+ if (deltaX === 0 && deltaY === 0) throw flowError(profileId, index, "wheel requires a non-zero deltaX or deltaY");
635
+ if (locator) await locator.hover();
636
+ await ownerPage.mouse.wheel(deltaX, deltaY);
637
+ break;
638
+ }
639
+ case "evaluate":
640
+ await executeSafeEvaluation({ page: scope, locator, step, profileId, index, observations });
400
641
  break;
401
642
  case "waitFor": await requireLocator(locator, profileId, index).waitFor({ state: validLocatorState(step.state) }); break;
402
643
  case "waitForTimeout":
403
644
  if (!finitePositive(step.timeoutMs) || step.timeoutMs > 5_000) throw flowError(profileId, index, "waitForTimeout must be between 1 and 5000 ms");
404
- await page.waitForTimeout(step.timeoutMs);
645
+ await ownerPage.waitForTimeout(step.timeoutMs);
646
+ break;
647
+ case "assertVisible": {
648
+ const target = requireLocator(locator, profileId, index);
649
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be visible", check: () => target.isVisible() });
650
+ break;
651
+ }
652
+ case "assertHidden": {
653
+ const target = requireLocator(locator, profileId, index);
654
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be hidden", check: () => target.isHidden() });
655
+ break;
656
+ }
657
+ case "assertEnabled": {
658
+ const target = requireLocator(locator, profileId, index);
659
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be enabled", check: () => target.isEnabled() });
660
+ break;
661
+ }
662
+ case "assertDisabled": {
663
+ const target = requireLocator(locator, profileId, index);
664
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be disabled", check: () => target.isDisabled() });
665
+ break;
666
+ }
667
+ case "assertChecked": {
668
+ const target = requireLocator(locator, profileId, index);
669
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be checked", check: () => target.isChecked() });
670
+ break;
671
+ }
672
+ case "assertUnchecked": {
673
+ const target = requireLocator(locator, profileId, index);
674
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be unchecked", check: async () => !(await target.isChecked()) });
405
675
  break;
406
- case "assertVisible": assertCondition(await requireLocator(locator, profileId, index).isVisible(), profileId, index, "expected locator to be visible"); break;
407
- case "assertHidden": assertCondition(await requireLocator(locator, profileId, index).isHidden(), profileId, index, "expected locator to be hidden"); break;
408
- case "assertEnabled": assertCondition(await requireLocator(locator, profileId, index).isEnabled(), profileId, index, "expected locator to be enabled"); break;
409
- case "assertDisabled": assertCondition(await requireLocator(locator, profileId, index).isDisabled(), profileId, index, "expected locator to be disabled"); break;
410
- case "assertChecked": assertCondition(await requireLocator(locator, profileId, index).isChecked(), profileId, index, "expected locator to be checked"); break;
411
- case "assertUnchecked": assertCondition(!(await requireLocator(locator, profileId, index).isChecked()), profileId, index, "expected locator to be unchecked"); break;
676
+ }
412
677
  case "assertText": {
413
- const actual = (await requireLocator(locator, profileId, index).textContent()) ?? "";
414
- assertExpectedString(actual, step, profileId, index, "text");
678
+ const matches = expectedStringMatcher(step, profileId, index, "text");
679
+ const target = requireLocator(locator, profileId, index);
680
+ await retryAssertion({
681
+ page: ownerPage, timeout, profileId, index, reason: "text assertion failed",
682
+ check: async () => matches((await target.textContent()) ?? ""),
683
+ });
415
684
  break;
416
685
  }
417
686
  case "assertValue": {
418
- const actual = await requireLocator(locator, profileId, index).inputValue();
419
- assertExpectedString(actual, step, profileId, index, "value");
687
+ const matches = expectedStringMatcher(step, profileId, index, "value");
688
+ const target = requireLocator(locator, profileId, index);
689
+ await retryAssertion({
690
+ page: ownerPage, timeout, profileId, index, reason: "value assertion failed",
691
+ check: async () => matches(await target.inputValue()),
692
+ });
420
693
  break;
421
694
  }
422
- case "assertCount":
695
+ case "assertAttribute": {
696
+ if (typeof step.attribute !== "string" || !ATTRIBUTE_NAME.test(step.attribute)) {
697
+ throw flowError(profileId, index, "assertAttribute requires a valid attribute name");
698
+ }
699
+ const matches = expectedStringMatcher(step, profileId, index, "attribute");
700
+ const target = requireLocator(locator, profileId, index);
701
+ await retryAssertion({
702
+ page: ownerPage, timeout, profileId, index, reason: "attribute assertion failed",
703
+ check: async () => {
704
+ const actual = await target.getAttribute(step.attribute);
705
+ return actual !== null && matches(actual);
706
+ },
707
+ });
708
+ break;
709
+ }
710
+ case "assertCount": {
423
711
  if (!Number.isInteger(step.equals) || step.equals < 0) throw flowError(profileId, index, "assertCount requires a non-negative integer equals");
424
- assertCondition((await requireLocator(locator, profileId, index).count()) === step.equals, profileId, index, "locator count assertion failed");
712
+ const target = requireLocator(locator, profileId, index);
713
+ await retryAssertion({
714
+ page: ownerPage, timeout, profileId, index, reason: "locator count assertion failed",
715
+ check: async () => (await target.count()) === step.equals,
716
+ });
717
+ break;
718
+ }
719
+ case "assertDOMMetric": {
720
+ if (typeof step.metric !== "string") throw flowError(profileId, index, "assertDOMMetric requires metric");
721
+ const initialMetrics = await collectDomMetrics(scope, locator);
722
+ if (!Object.prototype.hasOwnProperty.call(initialMetrics, step.metric)) {
723
+ throw flowError(profileId, index, `unsupported DOM metric: ${step.metric}`);
724
+ }
725
+ const matches = numericComparisonMatcher(step, profileId, index);
726
+ await retryAssertion({
727
+ page: ownerPage, timeout, profileId, index, reason: `${step.metric} assertion failed`,
728
+ check: async () => matches((await collectDomMetrics(scope, locator))[step.metric]),
729
+ });
730
+ break;
731
+ }
732
+ case "assertURL": {
733
+ const matches = expectedStringMatcher(step, profileId, index, "URL");
734
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "URL assertion failed", check: () => matches(scope.url()) });
425
735
  break;
426
- case "assertURL": assertExpectedString(page.url(), step, profileId, index, "URL"); break;
736
+ }
427
737
  case "authRejectedIf": {
428
- const urlRejected = typeof step.urlIncludes === "string" && page.url().includes(step.urlIncludes);
738
+ const urlRejected = typeof step.urlIncludes === "string" && scope.url().includes(step.urlIncludes);
429
739
  const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
430
740
  if (!urlRejected && !locatorRejected) break;
431
741
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "application rejected or expired the configured authentication", EXIT_AUTH_UPDATE_REQUIRED, profileId);
@@ -433,13 +743,377 @@ async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, p
433
743
  case "screenshot": {
434
744
  const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
435
745
  if (!isSafeName(name)) throw flowError(profileId, index, "screenshot name is invalid");
436
- await assertPageDoesNotExposeSecrets(page, secrets, profileId);
437
- await page.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
746
+ await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
747
+ await ownerPage.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
438
748
  break;
439
749
  }
440
750
  default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
441
751
  }
442
- await assertPageDoesNotExposeSecrets(page, secrets, profileId);
752
+ if (unexpectedPopupError) throw unexpectedPopupError;
753
+ await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
754
+ }
755
+ } finally {
756
+ context.off("page", onContextPage);
757
+ }
758
+ }
759
+
760
+ async function resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index }) {
761
+ if (step.target === undefined || (isObject(step.target) && step.target.type === "main")) {
762
+ return { scope: page, ownerPage: page };
763
+ }
764
+ if (!isObject(step.target)) throw flowError(profileId, index, "target must be an object");
765
+ if (step.target.type === "popup") {
766
+ if (!isSafeName(step.target.name) || !popups.has(step.target.name)) throw flowError(profileId, index, "popup target is missing or unknown");
767
+ const record = popups.get(step.target.name);
768
+ const popup = record.page;
769
+ if (popup.isClosed()) throw flowError(profileId, index, "popup target is closed");
770
+ assertPageOrigin(popup, record.origin, allowedOrigins, profileId, index, "popup target");
771
+ return { scope: popup, ownerPage: popup };
772
+ }
773
+ if (step.target.type === "frame") {
774
+ const popupRecord = step.target.page === undefined ? undefined : popups.get(step.target.page);
775
+ const ownerPage = step.target.page === undefined ? page : popupRecord?.page;
776
+ if (!ownerPage || ownerPage.isClosed() || (step.target.page !== undefined && !isSafeName(step.target.page))) {
777
+ throw flowError(profileId, index, "frame target page is missing or unknown");
778
+ }
779
+ const frameLocator = resolveLocator(ownerPage, step.target.locator, profileId, index);
780
+ const handle = await frameLocator.elementHandle();
781
+ const frame = await handle?.contentFrame();
782
+ if (!frame) throw flowError(profileId, index, "frame target did not resolve to an iframe");
783
+ let origin;
784
+ try { origin = await frame.evaluate(() => location.origin); } catch { /* rejected below */ }
785
+ let ownerOrigin;
786
+ try { ownerOrigin = new URL(ownerPage.url()).origin; } catch { /* rejected below */ }
787
+ if (!allowedOrigins.includes(origin) || origin !== ownerOrigin) throw flowError(profileId, index, "frame target must be same-origin with its page");
788
+ return { scope: frame, ownerPage };
789
+ }
790
+ throw flowError(profileId, index, "target type must be main, popup, or frame");
791
+ }
792
+
793
+ function assertSameOriginPage(page, ownerPage, allowedOrigins, profileId, index, label) {
794
+ let ownerOrigin;
795
+ try { ownerOrigin = new URL(ownerPage.url()).origin; } catch { /* rejected below */ }
796
+ return assertPageOrigin(page, ownerOrigin, allowedOrigins, profileId, index, label);
797
+ }
798
+
799
+ function assertPageOrigin(page, expectedOrigin, allowedOrigins, profileId, index, label) {
800
+ try {
801
+ const url = new URL(page.url());
802
+ if (!url.username && !url.password && allowedOrigins.includes(url.origin) && url.origin === expectedOrigin) return url.origin;
803
+ } catch { /* rejected below */ }
804
+ throw flowError(profileId, index, `${label} must be same-origin with its opener`);
805
+ }
806
+
807
+ async function executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action }) {
808
+ const watchers = [];
809
+ try {
810
+ if (step.expectResponse !== undefined) watchers.push(createResponseWatcher(ownerPage, step.expectResponse, allowedOrigins, timeout, profileId, index));
811
+ if (step.expectDialog !== undefined) watchers.push(createDialogWatcher(ownerPage, step.expectDialog, timeout, profileId, index));
812
+ await Promise.all([action(), ...watchers.map((watcher) => watcher.promise)]);
813
+ } finally {
814
+ for (const watcher of watchers) watcher.cancel();
815
+ }
816
+ }
817
+
818
+ function createResponseWatcher(page, expectation, allowedOrigins, timeout, profileId, index) {
819
+ if (!isObject(expectation)
820
+ || typeof expectation.path !== "string"
821
+ || !expectation.path.startsWith("/")
822
+ || expectation.path.startsWith("//")
823
+ || /[?#\\]/.test(expectation.path)
824
+ || !HTTP_METHODS.has(expectation.method)
825
+ || !Number.isInteger(expectation.status)
826
+ || expectation.status < 100
827
+ || expectation.status > 599) {
828
+ throw flowError(profileId, index, "expectResponse requires exact path, method, and status");
829
+ }
830
+ let settled = false;
831
+ let settle;
832
+ const matchingRequests = new Set();
833
+ const promise = new Promise((resolve, reject) => {
834
+ settle = { resolve, reject };
835
+ });
836
+ const cleanup = () => {
837
+ clearTimeout(timer);
838
+ page.off("request", onRequest);
839
+ page.off("response", onResponse);
840
+ };
841
+ const finish = (error) => {
842
+ if (settled) return;
843
+ settled = true;
844
+ cleanup();
845
+ if (error) settle.reject(error);
846
+ else settle.resolve();
847
+ };
848
+ const onRequest = (request) => {
849
+ try {
850
+ const url = new URL(request.url());
851
+ if (!allowedOrigins.includes(url.origin)
852
+ || url.pathname !== expectation.path
853
+ || request.method() !== expectation.method) return;
854
+ matchingRequests.add(request);
855
+ } catch { /* unrelated request */ }
856
+ };
857
+ const onResponse = (response) => {
858
+ try {
859
+ if (!matchingRequests.has(response.request()) || response.status() !== expectation.status) return;
860
+ finish();
861
+ } catch { /* unrelated response */ }
862
+ };
863
+ const timer = setTimeout(() => finish(flowError(profileId, index, "expected response was not observed before timeout")), timeout);
864
+ page.on("request", onRequest);
865
+ page.on("response", onResponse);
866
+ return { promise, cancel: () => finish() };
867
+ }
868
+
869
+ function createDialogWatcher(page, expectation, timeout, profileId, index) {
870
+ if (!isObject(expectation)
871
+ || !DIALOG_TYPES.has(expectation.type)
872
+ || !isObject(expectation.message)
873
+ || typeof expectation.accept !== "boolean") {
874
+ throw flowError(profileId, index, "expectDialog requires type, message matcher, and accept");
875
+ }
876
+ const matches = expectedStringMatcher(expectation.message, profileId, index, "dialog message");
877
+ let settled = false;
878
+ let settle;
879
+ const promise = new Promise((resolve, reject) => {
880
+ settle = { resolve, reject };
881
+ });
882
+ const cleanup = () => {
883
+ clearTimeout(timer);
884
+ page.off("dialog", onDialog);
885
+ };
886
+ const finish = (error) => {
887
+ if (settled) return;
888
+ settled = true;
889
+ cleanup();
890
+ if (error) settle.reject(error);
891
+ else settle.resolve();
892
+ };
893
+ const onDialog = async (dialog) => {
894
+ const matched = dialog.type() === expectation.type && matches(dialog.message());
895
+ try {
896
+ if (matched && expectation.accept) await dialog.accept();
897
+ else await dialog.dismiss();
898
+ finish(matched ? undefined : flowError(profileId, index, "native dialog did not match expectation"));
899
+ } catch {
900
+ finish(flowError(profileId, index, "native dialog could not be handled"));
901
+ }
902
+ };
903
+ const timer = setTimeout(() => finish(flowError(profileId, index, "expected native dialog was not observed before timeout")), timeout);
904
+ page.on("dialog", onDialog);
905
+ return { promise, cancel: () => finish() };
906
+ }
907
+
908
+ function resolvePoint(value, profileId, index, label) {
909
+ if (!isObject(value)
910
+ || typeof value.x !== "number" || !Number.isFinite(value.x) || value.x < 0 || value.x > 100_000
911
+ || typeof value.y !== "number" || !Number.isFinite(value.y) || value.y < 0 || value.y > 100_000) {
912
+ throw flowError(profileId, index, `${label} requires bounded non-negative x and y`);
913
+ }
914
+ return { x: value.x, y: value.y };
915
+ }
916
+
917
+ function decodeUploadFiles(value, profileId, index) {
918
+ if (!Array.isArray(value) || value.length > MAX_UPLOAD_FILES) {
919
+ throw flowError(profileId, index, `uploadFiles requires an array of at most ${MAX_UPLOAD_FILES} files`);
920
+ }
921
+ let totalBytes = 0;
922
+ return value.map((file) => {
923
+ if (!isObject(file)
924
+ || typeof file.name !== "string" || file.name.length === 0 || file.name.length > 128 || /[\\/\0-\x1f]/.test(file.name)
925
+ || typeof file.mimeType !== "string" || file.mimeType.length > 128 || !/^[\w!#$&^_.+-]+\/[\w!#$&^_.+-]+$/.test(file.mimeType)
926
+ || typeof file.base64 !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(file.base64)) {
927
+ throw flowError(profileId, index, "uploadFiles entries require a safe name, MIME type, and canonical base64");
928
+ }
929
+ const buffer = Buffer.from(file.base64, "base64");
930
+ if (buffer.toString("base64") !== file.base64 || buffer.length > MAX_UPLOAD_FILE_BYTES) {
931
+ throw flowError(profileId, index, `uploadFiles entries may not exceed ${MAX_UPLOAD_FILE_BYTES} bytes`);
932
+ }
933
+ totalBytes += buffer.length;
934
+ if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) throw flowError(profileId, index, `uploadFiles total may not exceed ${MAX_UPLOAD_TOTAL_BYTES} bytes`);
935
+ return { name: file.name, mimeType: file.mimeType, buffer };
936
+ });
937
+ }
938
+
939
+ async function executeDownload({ ownerPage, locator, step, evidenceDir, allowedOrigins, timeout, profileId, index, secrets }) {
940
+ if (!isObject(step.filename)) throw flowError(profileId, index, "download requires a filename matcher");
941
+ const filenameMatches = expectedStringMatcher(step.filename, profileId, index, "download filename");
942
+ const maxBytes = step.maxBytes === undefined ? DEFAULT_DOWNLOAD_MAX_BYTES : step.maxBytes;
943
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) {
944
+ throw flowError(profileId, index, `download maxBytes must be between 1 and ${MAX_DOWNLOAD_BYTES}`);
945
+ }
946
+ if (step.retain !== undefined && typeof step.retain !== "boolean") throw flowError(profileId, index, "download retain must be boolean");
947
+ if (step.retain === true && !isSafeName(step.name)) throw flowError(profileId, index, "retained download requires a safe name");
948
+ const retained = step.retain === true ? path.join(evidenceDir, `download-${step.name}.bin`) : undefined;
949
+ if (retained && fs.existsSync(retained)) throw flowError(profileId, index, `retained download name is duplicated: ${step.name}`);
950
+ const downloadPromise = ownerPage.waitForEvent("download", { timeout });
951
+ const [, download] = await Promise.all([locator.click(), downloadPromise]);
952
+ try {
953
+ const url = new URL(download.url());
954
+ if (!allowedOrigins.includes(url.origin)) throw flowError(profileId, index, "download is outside allowedOrigins");
955
+ } catch (error) {
956
+ await download.cancel().catch(() => {});
957
+ if (error instanceof QaStatusError) throw error;
958
+ throw flowError(profileId, index, "download is outside allowedOrigins");
959
+ }
960
+ if (!filenameMatches(download.suggestedFilename())) {
961
+ await download.cancel().catch(() => {});
962
+ throw flowError(profileId, index, "download filename did not match expectation");
963
+ }
964
+ const temporary = path.join(evidenceDir, `.download-${index + 1}.tmp`);
965
+ try {
966
+ await saveDownloadWithLimit(download, temporary, maxBytes, profileId, index);
967
+ assertFileDoesNotExposeSecrets(temporary, secrets, profileId);
968
+ if (retained) {
969
+ fs.renameSync(temporary, retained);
970
+ fs.chmodSync(retained, 0o600);
971
+ }
972
+ } finally {
973
+ fs.rmSync(temporary, { force: true });
974
+ }
975
+ }
976
+
977
+ async function saveDownloadWithLimit(download, destination, maxBytes, profileId, index) {
978
+ let completed = false;
979
+ let failure;
980
+ const save = download.saveAs(destination).then(
981
+ () => { completed = true; },
982
+ (error) => { completed = true; failure = error; },
983
+ );
984
+ let exceeded = false;
985
+ while (!completed) {
986
+ await new Promise((resolve) => setTimeout(resolve, 10));
987
+ if (!fs.existsSync(destination) || fs.statSync(destination).size <= maxBytes) continue;
988
+ exceeded = true;
989
+ await download.cancel().catch(() => {});
990
+ break;
991
+ }
992
+ await save;
993
+ if (exceeded || (fs.existsSync(destination) && fs.statSync(destination).size > maxBytes)) {
994
+ throw flowError(profileId, index, "download exceeded maxBytes");
995
+ }
996
+ if (failure) throw flowError(profileId, index, "download could not be saved");
997
+ }
998
+
999
+ function assertFileDoesNotExposeSecrets(file, secrets, profileId) {
1000
+ if (secrets.length === 0) return;
1001
+ const bytes = fs.readFileSync(file);
1002
+ if (!secrets.some((secret) => secret && bytes.includes(Buffer.from(secret)))) return;
1003
+ const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in a retained download; retained evidence was discarded", 1, profileId);
1004
+ error.suppressEvidence = true;
1005
+ throw error;
1006
+ }
1007
+
1008
+ async function executeSafeEvaluation({ page, locator, step, profileId, index, observations }) {
1009
+ switch (step.operation) {
1010
+ case "scrollTo": {
1011
+ const position = {
1012
+ x: scrollPosition(step.x, profileId, index, "evaluate scrollTo x"),
1013
+ y: scrollPosition(step.y, profileId, index, "evaluate scrollTo y"),
1014
+ };
1015
+ if (position.x === undefined && position.y === undefined) throw flowError(profileId, index, "evaluate scrollTo requires x or y");
1016
+ if (locator) {
1017
+ await locator.evaluate((element, next) => {
1018
+ const maxX = Math.max(0, element.scrollWidth - element.clientWidth);
1019
+ const maxY = Math.max(0, element.scrollHeight - element.clientHeight);
1020
+ const x = next.x === "max" ? maxX : next.x ?? element.scrollLeft;
1021
+ const y = next.y === "max" ? maxY : next.y ?? element.scrollTop;
1022
+ element.scrollTo(x, y);
1023
+ }, position);
1024
+ } else {
1025
+ await page.evaluate((next) => {
1026
+ const root = document.scrollingElement ?? document.documentElement;
1027
+ const maxX = Math.max(0, root.scrollWidth - window.innerWidth);
1028
+ const maxY = Math.max(0, root.scrollHeight - window.innerHeight);
1029
+ const x = next.x === "max" ? maxX : next.x ?? window.scrollX;
1030
+ const y = next.y === "max" ? maxY : next.y ?? window.scrollY;
1031
+ window.scrollTo(x, y);
1032
+ }, position);
1033
+ }
1034
+ break;
1035
+ }
1036
+ case "scrollBy": {
1037
+ const deltaX = optionalFiniteNumber(step.deltaX, 0, profileId, index, "evaluate scrollBy deltaX");
1038
+ const deltaY = optionalFiniteNumber(step.deltaY, 0, profileId, index, "evaluate scrollBy deltaY");
1039
+ if (deltaX === 0 && deltaY === 0) throw flowError(profileId, index, "evaluate scrollBy requires a non-zero deltaX or deltaY");
1040
+ if (locator) await locator.evaluate((element, delta) => element.scrollBy(delta.x, delta.y), { x: deltaX, y: deltaY });
1041
+ else await page.evaluate((delta) => window.scrollBy(delta.x, delta.y), { x: deltaX, y: deltaY });
1042
+ break;
1043
+ }
1044
+ case "metrics": {
1045
+ const name = step.name === undefined ? `metrics-${index + 1}` : step.name;
1046
+ if (!isSafeName(name)) throw flowError(profileId, index, "evaluate metrics name is invalid");
1047
+ if (observations.some((entry) => entry.name === name)) throw flowError(profileId, index, `evaluate metrics name is duplicated: ${name}`);
1048
+ observations.push({ name, step: index + 1, value: await collectDomMetrics(page, locator) });
1049
+ break;
1050
+ }
1051
+ default:
1052
+ throw flowError(profileId, index, "evaluate requires operation scrollTo, scrollBy, or metrics; executable JavaScript is not allowed");
1053
+ }
1054
+ }
1055
+
1056
+ async function collectDomMetrics(page, locator) {
1057
+ if (locator) {
1058
+ return locator.evaluate((element) => {
1059
+ const rect = element.getBoundingClientRect();
1060
+ return {
1061
+ scrollLeft: element.scrollLeft,
1062
+ scrollTop: element.scrollTop,
1063
+ scrollWidth: element.scrollWidth,
1064
+ scrollHeight: element.scrollHeight,
1065
+ clientWidth: element.clientWidth,
1066
+ clientHeight: element.clientHeight,
1067
+ x: rect.x,
1068
+ y: rect.y,
1069
+ width: rect.width,
1070
+ height: rect.height,
1071
+ };
1072
+ });
1073
+ }
1074
+ return page.evaluate(() => {
1075
+ const root = document.scrollingElement ?? document.documentElement;
1076
+ return {
1077
+ scrollX: window.scrollX,
1078
+ scrollY: window.scrollY,
1079
+ scrollWidth: root.scrollWidth,
1080
+ scrollHeight: root.scrollHeight,
1081
+ clientWidth: root.clientWidth,
1082
+ clientHeight: root.clientHeight,
1083
+ viewportWidth: window.innerWidth,
1084
+ viewportHeight: window.innerHeight,
1085
+ };
1086
+ });
1087
+ }
1088
+
1089
+ function scrollPosition(value, profileId, index, label) {
1090
+ if (value === undefined || value === "max") return value;
1091
+ return finiteNumber(value, profileId, index, label);
1092
+ }
1093
+
1094
+ function optionalFiniteNumber(value, fallback, profileId, index, label) {
1095
+ return value === undefined ? fallback : finiteNumber(value, profileId, index, label);
1096
+ }
1097
+
1098
+ function finiteNumber(value, profileId, index, label) {
1099
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > MAX_SCROLL_DISTANCE) {
1100
+ throw flowError(profileId, index, `${label} must be a finite number between -${MAX_SCROLL_DISTANCE} and ${MAX_SCROLL_DISTANCE}`);
1101
+ }
1102
+ return value;
1103
+ }
1104
+
1105
+ function numericComparisonMatcher(step, profileId, index) {
1106
+ const comparisons = ["equals", "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual"]
1107
+ .filter((key) => typeof step[key] === "number" && Number.isFinite(step[key]));
1108
+ if (comparisons.length !== 1) throw flowError(profileId, index, "assertDOMMetric requires exactly one numeric comparator");
1109
+ const key = comparisons[0];
1110
+ const expected = step[key];
1111
+ switch (key) {
1112
+ case "equals": return (actual) => actual === expected;
1113
+ case "greaterThan": return (actual) => actual > expected;
1114
+ case "greaterThanOrEqual": return (actual) => actual >= expected;
1115
+ case "lessThan": return (actual) => actual < expected;
1116
+ case "lessThanOrEqual": return (actual) => actual <= expected;
443
1117
  }
444
1118
  }
445
1119
 
@@ -460,15 +1134,25 @@ function requireLocator(locator, profileId, index) {
460
1134
  return locator;
461
1135
  }
462
1136
 
463
- function assertExpectedString(actual, step, profileId, index, label) {
1137
+ function expectedStringMatcher(step, profileId, index, label) {
464
1138
  const hasEquals = typeof step.equals === "string";
465
1139
  const hasIncludes = typeof step.includes === "string";
466
1140
  if (hasEquals === hasIncludes) throw flowError(profileId, index, `${label} assertion requires exactly one of equals or includes`);
467
- assertCondition(hasEquals ? actual === step.equals : actual.includes(step.includes), profileId, index, `${label} assertion failed`);
1141
+ return hasEquals ? (actual) => actual === step.equals : (actual) => actual.includes(step.includes);
468
1142
  }
469
1143
 
470
- function assertCondition(condition, profileId, index, reason) {
471
- if (!condition) throw flowError(profileId, index, reason);
1144
+ async function retryAssertion({ page, timeout, profileId, index, reason, check }) {
1145
+ const deadline = Date.now() + timeout;
1146
+ while (true) {
1147
+ try {
1148
+ if (await check()) return;
1149
+ } catch {
1150
+ // Dynamic UI may temporarily detach or replace the target; retry until the assertion deadline.
1151
+ }
1152
+ const remaining = deadline - Date.now();
1153
+ if (remaining <= 0) throw flowError(profileId, index, reason);
1154
+ await page.waitForTimeout(Math.min(ASSERTION_POLL_INTERVAL_MS, remaining));
1155
+ }
472
1156
  }
473
1157
 
474
1158
  function flowError(profileId, index, reason) {
@@ -664,12 +1348,195 @@ function parseArgs(values) {
664
1348
  const value = values[index + 1];
665
1349
  if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
666
1350
  const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
667
- if (!["profile", "flow", "baseUrl", "runId"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
1351
+ if (!["profile", "flow", "baseUrl", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
668
1352
  result[key] = value;
669
1353
  }
670
1354
  return result;
671
1355
  }
672
1356
 
1357
+ function parseRunnerTimeout(value, profileId) {
1358
+ if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
1359
+ const parsed = Number(value);
1360
+ if (!Number.isFinite(parsed) || parsed < 100 || parsed > MAX_RUNNER_TIMEOUT_MS) {
1361
+ throw new QaStatusError(
1362
+ "QA_RUN_FAILED",
1363
+ `--runner-timeout-ms must be between 100 and ${MAX_RUNNER_TIMEOUT_MS}`,
1364
+ 1,
1365
+ profileId,
1366
+ );
1367
+ }
1368
+ return Math.round(parsed);
1369
+ }
1370
+
1371
+ function createRunnerProgress(workspaceDir) {
1372
+ const progressPath = path.join(workspaceDir, "progress.jsonl");
1373
+ let lastStage;
1374
+ const write = (stage, details = {}) => {
1375
+ lastStage = stage;
1376
+ if (fs.existsSync(progressPath) && fs.statSync(progressPath).size >= PROGRESS_LOG_MAX_BYTES) {
1377
+ fs.writeFileSync(progressPath, "", { mode: 0o600 });
1378
+ }
1379
+ fs.appendFileSync(progressPath, `${JSON.stringify({ at: new Date().toISOString(), stage, ...details })}\n`, { mode: 0o600 });
1380
+ };
1381
+ write.lastStage = () => lastStage;
1382
+ return write;
1383
+ }
1384
+
1385
+ function terminateRunnerDescendants() {
1386
+ if (process.platform === "win32") {
1387
+ // Detached children are not covered by a POSIX process-group kill. Walk
1388
+ // the Windows process tree explicitly so a timed-out Playwright/browser
1389
+ // child cannot keep the private QA workspace locked after the runner exits.
1390
+ const script = `
1391
+ $root = ${process.pid}
1392
+ $processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId)
1393
+ $pending = [System.Collections.Generic.Queue[int]]::new()
1394
+ $pending.Enqueue($root)
1395
+ $descendants = [System.Collections.Generic.HashSet[int]]::new()
1396
+ while ($pending.Count -gt 0) {
1397
+ $parent = $pending.Dequeue()
1398
+ foreach ($candidate in $processes) {
1399
+ $candidatePid = [int]$candidate.ProcessId
1400
+ if ([int]$candidate.ParentProcessId -eq $parent -and $candidatePid -ne $root -and $descendants.Add($candidatePid)) {
1401
+ $pending.Enqueue($candidatePid)
1402
+ }
1403
+ }
1404
+ }
1405
+ foreach ($candidatePid in $descendants) {
1406
+ Stop-Process -Id $candidatePid -Force -ErrorAction SilentlyContinue
1407
+ }
1408
+ `;
1409
+ spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
1410
+ stdio: "ignore",
1411
+ timeout: 2_000,
1412
+ windowsHide: true,
1413
+ });
1414
+ return;
1415
+ }
1416
+ const snapshot = spawnSync("ps", ["-axo", "pid=,ppid=,pgid="], {
1417
+ encoding: "utf8",
1418
+ timeout: 1000,
1419
+ maxBuffer: 2 * 1024 * 1024,
1420
+ });
1421
+ if (snapshot.status !== 0 || !snapshot.stdout) return;
1422
+ const processes = snapshot.stdout
1423
+ .split("\n")
1424
+ .map((line) => line.trim().split(/\s+/).map(Number))
1425
+ .filter(([pid, parentPid, groupId]) => Number.isInteger(pid) && Number.isInteger(parentPid) && Number.isInteger(groupId))
1426
+ .map(([pid, parentPid, groupId]) => ({ pid, parentPid, groupId }));
1427
+ const descendants = [];
1428
+ const pendingParents = [process.pid];
1429
+ for (let index = 0; index < pendingParents.length; index += 1) {
1430
+ const parentPid = pendingParents[index];
1431
+ for (const candidate of processes) {
1432
+ if (candidate.parentPid !== parentPid || descendants.some(({ pid }) => pid === candidate.pid)) continue;
1433
+ descendants.push(candidate);
1434
+ pendingParents.push(candidate.pid);
1435
+ }
1436
+ }
1437
+ const ownGroup = processes.find(({ pid }) => pid === process.pid)?.groupId;
1438
+ for (const { pid, groupId } of descendants) {
1439
+ if (pid !== groupId || groupId === ownGroup) continue;
1440
+ try {
1441
+ process.kill(-groupId, "SIGKILL");
1442
+ } catch { /* already gone */ }
1443
+ }
1444
+ for (const { pid } of descendants) {
1445
+ try {
1446
+ process.kill(pid, "SIGKILL");
1447
+ } catch { /* already gone */ }
1448
+ }
1449
+ }
1450
+
1451
+ function sanitizeTraceArchiveInWorker(tracePath, secrets, deadline) {
1452
+ return new Promise((resolve, reject) => {
1453
+ const worker = new Worker(new URL(import.meta.url), {
1454
+ workerData: { operation: "sanitize_trace", tracePath, secrets },
1455
+ resourceLimits: { maxOldGenerationSizeMb: 256 },
1456
+ });
1457
+ let settled = false;
1458
+ const finish = (error) => {
1459
+ if (settled) return;
1460
+ settled = true;
1461
+ clearTimeout(timer);
1462
+ void worker.terminate();
1463
+ if (error) reject(error);
1464
+ else resolve();
1465
+ };
1466
+ const timer = setTimeout(
1467
+ () => finish(new RunnerStageTimeoutError("trace_sanitize")),
1468
+ Math.max(1, deadline - Date.now()),
1469
+ );
1470
+ worker.once("message", (message) => {
1471
+ if (message?.success === true) finish();
1472
+ else finish(new Error(typeof message?.reason === "string" ? message.reason : "trace sanitization failed"));
1473
+ });
1474
+ worker.once("error", finish);
1475
+ worker.once("exit", (code) => {
1476
+ if (code !== 0) finish(new Error(`trace sanitizer exited with code ${code}`));
1477
+ });
1478
+ });
1479
+ }
1480
+
1481
+ async function runStage(progress, stage, deadline, operation, profileId) {
1482
+ progress(`${stage}_started`);
1483
+ try {
1484
+ const result = await withDeadline(operation(), deadline, stage);
1485
+ progress(`${stage}_finished`);
1486
+ return result;
1487
+ } catch (error) {
1488
+ if (error instanceof RunnerStageTimeoutError) {
1489
+ progress(`${stage}_timed_out`);
1490
+ throw new QaStatusError(
1491
+ "QA_RUN_FAILED",
1492
+ `browser QA stage ${stage} timed out`,
1493
+ EXIT_RUNNER_TIMEOUT,
1494
+ profileId,
1495
+ { timedOut: true, lastStage: stage },
1496
+ );
1497
+ }
1498
+ progress(`${stage}_failed`, { errorType: error?.constructor?.name ?? typeof error });
1499
+ throw error;
1500
+ }
1501
+ }
1502
+
1503
+ async function runCleanupStage(progress, stage, operation, deadline = Date.now() + CLEANUP_TIMEOUT_MS) {
1504
+ progress(`${stage}_started`);
1505
+ try {
1506
+ const result = await withDeadline(operation(), deadline, stage);
1507
+ progress(`${stage}_finished`);
1508
+ return result;
1509
+ } catch (error) {
1510
+ progress(error instanceof RunnerStageTimeoutError ? `${stage}_timed_out` : `${stage}_failed`, {
1511
+ errorType: error?.constructor?.name ?? typeof error,
1512
+ });
1513
+ throw error;
1514
+ }
1515
+ }
1516
+
1517
+ class RunnerStageTimeoutError extends Error {
1518
+ constructor(stage) {
1519
+ super(`browser QA stage ${stage} timed out`);
1520
+ this.name = "RunnerStageTimeoutError";
1521
+ }
1522
+ }
1523
+
1524
+ async function withDeadline(promise, deadline, stage) {
1525
+ const remaining = deadline - Date.now();
1526
+ if (remaining <= 0) throw new RunnerStageTimeoutError(stage);
1527
+ let timer;
1528
+ try {
1529
+ return await Promise.race([
1530
+ promise,
1531
+ new Promise((_, reject) => {
1532
+ timer = setTimeout(() => reject(new RunnerStageTimeoutError(stage)), remaining);
1533
+ }),
1534
+ ]);
1535
+ } finally {
1536
+ if (timer) clearTimeout(timer);
1537
+ }
1538
+ }
1539
+
673
1540
  function collectSecrets(value, output = []) {
674
1541
  if (!isObject(value)) return output;
675
1542
  if (typeof value.token === "string" && value.token.length > 0) output.push(value.token);
@@ -721,13 +1588,21 @@ function expandSecretForms(values) {
721
1588
  return [...forms].filter(Boolean).sort((left, right) => right.length - left.length);
722
1589
  }
723
1590
 
724
- async function assertPageDoesNotExposeSecrets(page, secrets, profileId) {
725
- if (!page || page.isClosed() || secrets.length === 0) return;
726
- const haystacks = [page.url(), await page.content()];
727
- if (!secrets.some((secret) => secret && haystacks.some((value) => value.includes(secret)))) return;
728
- const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in rendered page content; visual evidence was discarded", 1, profileId);
729
- error.suppressEvidence = true;
730
- throw error;
1591
+ async function assertBrowserDoesNotExposeSecrets(context, fallbackPage, secrets, profileId) {
1592
+ if (secrets.length === 0) return;
1593
+ const pages = typeof context?.pages === "function" ? context.pages() : [fallbackPage];
1594
+ for (const page of pages) {
1595
+ if (!page || page.isClosed()) continue;
1596
+ const frames = typeof page.frames === "function" ? page.frames() : [page];
1597
+ for (const frame of frames) {
1598
+ let haystacks;
1599
+ try { haystacks = [frame.url(), await frame.content()]; } catch { continue; }
1600
+ if (!secrets.some((secret) => secret && haystacks.some((value) => value.includes(secret)))) continue;
1601
+ const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in rendered page content; visual evidence was discarded", 1, profileId);
1602
+ error.suppressEvidence = true;
1603
+ throw error;
1604
+ }
1605
+ }
731
1606
  }
732
1607
 
733
1608
  function sanitizeTraceArchive(tracePath, secrets) {
@@ -936,25 +1811,26 @@ function relativePath(cwd, value) {
936
1811
 
937
1812
  function existingEvidence(evidenceDir) {
938
1813
  return fs.readdirSync(evidenceDir)
939
- .filter((name) => /^(?:[A-Za-z0-9._-]+\.png|video\.webm|trace\.zip)$/.test(name))
1814
+ .filter((name) => /^(?:[A-Za-z0-9._-]+\.png|video(?:-popup-[A-Za-z0-9._-]+)?\.webm|trace\.zip|download-[A-Za-z0-9._-]+\.bin)$/.test(name))
940
1815
  .sort();
941
1816
  }
942
1817
 
943
1818
  function artifactManifest(evidenceDir, evidence) {
944
- const artifacts = { screenshots: [], videos: [], traces: [] };
1819
+ const artifacts = { screenshots: [], videos: [], traces: [], downloads: [] };
945
1820
  for (const name of evidence) {
946
1821
  const absolutePath = path.resolve(evidenceDir, name);
947
1822
  const artifact = { path: absolutePath, uri: pathToFileURL(absolutePath).href };
948
1823
  if (name.endsWith(".png")) artifacts.screenshots.push(artifact);
949
1824
  else if (name.endsWith(".webm")) artifacts.videos.push(artifact);
950
1825
  else if (name.endsWith(".zip")) artifacts.traces.push(artifact);
1826
+ else if (name.endsWith(".bin")) artifacts.downloads.push(artifact);
951
1827
  }
952
1828
  return artifacts;
953
1829
  }
954
1830
 
955
1831
  function removeVisualEvidence(evidenceDir) {
956
1832
  for (const name of fs.readdirSync(evidenceDir)) {
957
- if (/\.(?:png|webm|zip)$/.test(name)) fs.rmSync(path.join(evidenceDir, name), { force: true });
1833
+ if (/\.(?:png|webm|zip|bin)$/.test(name)) fs.rmSync(path.join(evidenceDir, name), { force: true });
958
1834
  }
959
1835
  }
960
1836