pi-ui-extend 1.0.24 → 1.0.30

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 (39) 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/icons.d.ts +1 -0
  5. package/dist/app/icons.js +2 -0
  6. package/dist/app/input/input-controller.d.ts +2 -0
  7. package/dist/app/input/input-controller.js +8 -5
  8. package/dist/app/rendering/render-controller.js +2 -0
  9. package/dist/app/rendering/status-line-renderer.d.ts +4 -1
  10. package/dist/app/rendering/status-line-renderer.js +12 -0
  11. package/dist/app/screen/mouse-controller.d.ts +4 -1
  12. package/dist/app/screen/mouse-controller.js +9 -0
  13. package/dist/app/session/agent-pause-controller.d.ts +30 -0
  14. package/dist/app/session/agent-pause-controller.js +178 -0
  15. package/dist/app/session/session-event-controller.d.ts +1 -0
  16. package/dist/app/session/session-event-controller.js +22 -15
  17. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  18. package/dist/app/session/session-lifecycle-controller.js +1 -0
  19. package/dist/app/types.d.ts +10 -0
  20. package/dist/config.js +1 -1
  21. package/dist/default-pix-config.js +2 -2
  22. package/external/pi-tools-suite/README.md +1 -1
  23. package/external/pi-tools-suite/docs/browser-qa-subagent.md +34 -2
  24. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +24 -18
  25. package/external/pi-tools-suite/src/async-subagents/core/config.ts +14 -3
  26. package/external/pi-tools-suite/src/async-subagents/core/process.ts +19 -0
  27. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +97 -17
  28. package/external/pi-tools-suite/src/async-subagents/core/stop.ts +4 -2
  29. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +150 -19
  30. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +78 -0
  31. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +80 -0
  32. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-flow.example.jsonc +54 -1
  33. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +1260 -79
  34. package/external/pi-tools-suite/src/dcp/index.ts +21 -0
  35. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +92 -0
  36. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +21 -16
  37. package/external/pi-tools-suite/src/todo/index.ts +86 -46
  38. package/external/pi-tools-suite/src/todo/todo.ts +1 -1
  39. package/package.json +1 -1
@@ -1,19 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
3
5
  import fs from "node:fs";
4
- import path from "node:path";
5
6
  import { createRequire } from "node:module";
7
+ import path from "node:path";
6
8
  import { pathToFileURL } from "node:url";
9
+ import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
7
10
  import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
8
11
  import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
9
12
 
10
13
  const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
14
+ const AUTH_SECRET_PLACEHOLDER = /^__PI_QA_SECRET_[1-9][0-9]*__$/;
11
15
  const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
12
16
  const QA_WORKSPACE_RELATIVE = "browser-qa";
13
17
  const EVIDENCE_RELATIVE = "evidence";
14
18
  const FORM_VIDEO_STEP_DELAY_MS = 250;
19
+ const POPUP_VIDEO_SETTLE_MS = 250;
15
20
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
21
+ const EXIT_RUNNER_TIMEOUT = 124;
22
+ const DEFAULT_RUNNER_TIMEOUT_MS = 90_000;
23
+ const MAX_RUNNER_TIMEOUT_MS = 100_000;
24
+ const CLEANUP_TIMEOUT_MS = 5_000;
25
+ const HARD_EXIT_GRACE_MS = 15_000;
26
+ const PROGRESS_LOG_MAX_BYTES = 1024 * 1024;
27
+ const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
28
+ const MIN_VIEWPORT = Object.freeze({ width: 320, height: 240 });
29
+ const MAX_VIEWPORT = Object.freeze({ width: 3840, height: 2160 });
30
+ const MAX_SCROLL_DISTANCE = 1_000_000;
31
+ const ASSERTION_POLL_INTERVAL_MS = 100;
32
+ const MAX_POPUPS = 3;
33
+ const MAX_UPLOAD_FILES = 10;
34
+ const MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
35
+ const MAX_UPLOAD_TOTAL_BYTES = 10 * 1024 * 1024;
36
+ const MAX_FLOW_BYTES = 16 * 1024 * 1024;
37
+ const DEFAULT_DOWNLOAD_MAX_BYTES = 5 * 1024 * 1024;
38
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
39
+ const DEFAULT_ENVIRONMENT = Object.freeze({
40
+ locale: "en-US",
41
+ timezoneId: "UTC",
42
+ colorScheme: "light",
43
+ reducedMotion: "reduce",
44
+ });
45
+ const ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_.:-]{0,127}$/;
16
46
  const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
47
+ const HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
48
+ const DIALOG_TYPES = new Set(["alert", "beforeunload", "confirm", "prompt"]);
17
49
  const AUTH_TEMPLATE = `{
18
50
  // Authenticated browser QA requires at least one project-local profile.
19
51
  // Fill and uncomment a profile below. Keep this file private.
@@ -37,7 +69,7 @@ const AUTH_TEMPLATE = `{
37
69
  }
38
70
  }
39
71
  `;
40
- process.umask(0o077);
72
+ if (isMainThread) process.umask(0o077);
41
73
 
42
74
  class QaStatusError extends Error {
43
75
  constructor(status, reason, exitCode, profileId, details = {}) {
@@ -50,10 +82,22 @@ class QaStatusError extends Error {
50
82
  }
51
83
  }
52
84
 
53
- main().catch((error) => {
85
+ if (!isMainThread && workerData?.operation === "sanitize_trace") {
86
+ if (process.env.BROWSER_QA_TEST_HANG_STAGE === "trace_sanitize") {
87
+ setInterval(() => {}, 1000);
88
+ } else {
89
+ try {
90
+ sanitizeTraceArchive(workerData.tracePath, workerData.secrets);
91
+ parentPort?.postMessage({ success: true });
92
+ } catch (error) {
93
+ parentPort?.postMessage({ success: false, reason: redact(safeReason(error), workerData.secrets) });
94
+ }
95
+ }
96
+ } else main().catch((error) => {
54
97
  const statusError = error instanceof QaStatusError
55
98
  ? error
56
99
  : new QaStatusError("QA_RUN_FAILED", safeReason(error), 1);
100
+ terminateRunnerDescendants();
57
101
  writeStatus({
58
102
  status: statusError.status,
59
103
  profile: statusError.profileId,
@@ -61,12 +105,18 @@ main().catch((error) => {
61
105
  reason: statusError.reason,
62
106
  ...statusError.details,
63
107
  });
64
- process.exitCode = statusError.exitCode;
108
+ process.exit(statusError.exitCode);
65
109
  });
66
110
 
67
111
  async function main() {
68
112
  const [command = "profiles", ...rawArgs] = process.argv.slice(2);
69
113
  const cwd = fs.realpathSync(process.cwd());
114
+ if (command === "auth") {
115
+ const [authCommand, ...authArgs] = rawArgs;
116
+ if (authCommand !== "scaffold") throw new QaStatusError("QA_RUN_FAILED", `unknown auth command: ${authCommand ?? "(missing)"}`, 1);
117
+ await runAuthScaffold(cwd, parseAuthScaffoldArgs(authArgs));
118
+ return;
119
+ }
70
120
  if (command === "profiles") {
71
121
  const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
72
122
  if (rawArgs.length > 0 && !requireAuth) {
@@ -80,25 +130,50 @@ async function main() {
80
130
 
81
131
  const args = parseArgs(rawArgs);
82
132
  const selected = args.profile
83
- ? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
133
+ ? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
84
134
  : createPublicProfile(args.baseUrl);
85
135
  const profileId = selected.id;
86
136
  const profile = selected.profile;
87
137
  const secrets = collectSecrets(profile.auth);
138
+ let hardExitTimer;
139
+ let progress;
88
140
  try {
89
141
  const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
90
- await runQa({ cwd, agentDir, args, profileId, profile });
142
+ const workspaceDir = path.join(agentDir, QA_WORKSPACE_RELATIVE);
143
+ const runnerTimeoutMs = parseRunnerTimeout(args.runnerTimeoutMs, profileId);
144
+ const deadline = Date.now() + runnerTimeoutMs;
145
+ progress = createRunnerProgress(workspaceDir);
146
+ progress("runner_started", { timeoutMs: runnerTimeoutMs });
147
+ hardExitTimer = setTimeout(() => {
148
+ const lastStage = progress?.lastStage?.();
149
+ progress?.("hard_timeout", { timeoutMs: runnerTimeoutMs });
150
+ terminateRunnerDescendants();
151
+ writeStatus({
152
+ status: "QA_RUN_FAILED",
153
+ profile: profileId,
154
+ reason: `browser QA runner did not exit within ${runnerTimeoutMs + HARD_EXIT_GRACE_MS} ms${lastStage ? `; last stage: ${lastStage}` : ""}`,
155
+ timedOut: true,
156
+ ...(lastStage ? { lastStage } : {}),
157
+ });
158
+ process.exit(EXIT_RUNNER_TIMEOUT);
159
+ }, runnerTimeoutMs + HARD_EXIT_GRACE_MS);
160
+ await runQa({ cwd, agentDir, args, profileId, profile, deadline, progress });
91
161
  } catch (error) {
92
162
  if (error instanceof QaStatusError) throw error;
93
163
  throw new QaStatusError("QA_RUN_FAILED", redact(safeReason(error), secrets), 1, profileId);
164
+ } finally {
165
+ if (hardExitTimer) clearTimeout(hardExitTimer);
166
+ progress?.("runner_finished");
94
167
  }
95
168
  }
96
169
 
97
- async function runQa({ cwd, agentDir, args, profileId, profile }) {
170
+ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progress }) {
98
171
  if (!args.flow) throw new QaStatusError("QA_RUN_FAILED", "--flow is required", 1, profileId);
99
172
  const workspaceDir = path.join(agentDir, QA_WORKSPACE_RELATIVE);
100
173
  const flowPath = resolveExistingPrivateFile(workspaceDir, args.flow, "QA flow", false);
101
174
  const flow = readFlow(flowPath, profileId);
175
+ const viewport = resolveViewport(flow, profileId);
176
+ const environment = resolveEnvironment(flow, profileId);
102
177
  const allowedOrigins = normalizeAllowedOrigins(profile.allowedOrigins, profileId);
103
178
  const baseURL = normalizeBaseUrl(args.baseUrl ?? profile.baseUrl ?? allowedOrigins[0], allowedOrigins, profileId);
104
179
  validateAuthConfiguration(cwd, profile.auth, allowedOrigins, profileId);
@@ -106,50 +181,62 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
106
181
  const evidenceDir = path.join(workspaceDir, EVIDENCE_RELATIVE, runId, profileId);
107
182
  createExclusivePrivateDirectory(agentDir, evidenceDir);
108
183
 
184
+ progress("playwright_load_started");
109
185
  const playwright = loadPlaywright(cwd);
110
- const browser = await playwright.chromium.launch({ headless: true });
186
+ progress("playwright_load_finished");
187
+ const browser = await runStage(progress, "browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
111
188
  let context;
112
189
  let page;
113
- let video;
190
+ const videos = [];
114
191
  let outcome = "passed";
115
192
  let failure;
193
+ const observations = [];
116
194
  const runtimeSecrets = collectSecrets(profile.auth);
117
195
  const tracePath = path.join(evidenceDir, "trace.zip");
118
196
  try {
119
- const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins });
120
- context = await browser.newContext({
197
+ const contextOptions = await runStage(progress, "auth_context_options", deadline, () => contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins }), profileId);
198
+ context = await runStage(progress, "context_create", deadline, () => browser.newContext({
121
199
  ...contextOptions,
122
200
  baseURL,
201
+ ...environment,
123
202
  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 });
203
+ recordVideo: { dir: evidenceDir, size: viewport },
204
+ viewport,
205
+ }), profileId);
206
+ await runStage(progress, "origin_guard_install", deadline, () => installOriginGuard(context, allowedOrigins, profile.auth), profileId);
207
+ await runStage(progress, "context_auth_apply", deadline, () => applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId), profileId);
208
+ page = await runStage(progress, "page_create", deadline, () => context.newPage(), profileId);
209
+ videos.push({ name: "video.webm", video: page.video() });
210
+ await runStage(progress, "form_auth_apply", deadline, () => applyFormAuth(page, profile.auth, allowedOrigins, profileId), profileId);
211
+ const initialStorageState = await runStage(progress, "storage_state_collect", deadline, () => context.storageState(), profileId);
212
+ runtimeSecrets.push(...collectStorageStateSecrets(initialStorageState));
213
+ await runStage(progress, "trace_start", deadline, () => context.tracing.start({ screenshots: true, snapshots: true, sources: false }), profileId);
214
+ await runStage(progress, "flow_execute", deadline, () => executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets, observations, videos }), profileId);
215
+ await runStage(progress, "secret_exposure_check", deadline, () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId), profileId);
216
+ await runStage(progress, "final_screenshot", deadline, () => page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true }), profileId);
137
217
  } catch (error) {
138
218
  outcome = error instanceof QaStatusError ? error.status : "failed";
139
219
  failure = error;
140
220
  if (page && !page.isClosed() && !error.suppressEvidence) {
141
221
  try {
142
- await page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true });
222
+ await runCleanupStage(progress, "failure_screenshot", () => page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true }));
143
223
  } catch { /* best effort */ }
144
224
  }
145
225
  } finally {
226
+ const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
146
227
  if (context) {
147
228
  try {
148
- runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
229
+ const finalStorageState = await runCleanupStage(progress, "storage_state_finalize", () => context.storageState(), cleanupDeadline);
230
+ runtimeSecrets.push(...collectStorageStateSecrets(finalStorageState));
149
231
  } catch { /* best effort */ }
150
232
  try {
151
- await context.tracing.stop({ path: tracePath });
152
- sanitizeTraceArchive(tracePath, runtimeSecrets);
233
+ await runCleanupStage(progress, "trace_stop", () => context.tracing.stop({ path: tracePath }), cleanupDeadline);
234
+ await runCleanupStage(
235
+ progress,
236
+ "trace_sanitize",
237
+ () => sanitizeTraceArchiveInWorker(tracePath, runtimeSecrets, cleanupDeadline),
238
+ cleanupDeadline,
239
+ );
153
240
  } catch (error) {
154
241
  fs.rmSync(tracePath, { force: true });
155
242
  if (!failure) {
@@ -157,15 +244,26 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
157
244
  failure = new Error(`trace evidence could not be sanitized: ${safeReason(error)}`);
158
245
  }
159
246
  }
160
- await context.close().catch(() => {});
247
+ await runCleanupStage(progress, "context_close", () => context.close(), cleanupDeadline).catch(() => {});
161
248
  }
162
- if (video) {
249
+ for (const [index, entry] of videos.entries()) {
250
+ if (!entry.video) continue;
163
251
  try {
164
- const generatedVideo = await video.path();
165
- if (fs.existsSync(generatedVideo)) fs.renameSync(generatedVideo, path.join(evidenceDir, "video.webm"));
252
+ const generatedVideo = await runCleanupStage(progress, `video_${index + 1}_finalize`, () => entry.video.path(), cleanupDeadline);
253
+ if (!fs.existsSync(generatedVideo)) continue;
254
+ if (entry.discard) fs.rmSync(generatedVideo, { force: true });
255
+ else fs.renameSync(generatedVideo, path.join(evidenceDir, entry.name));
166
256
  } catch { /* best effort */ }
167
257
  }
168
- await browser.close().catch(() => {});
258
+ await runCleanupStage(progress, "browser_close", () => browser.close(), cleanupDeadline).catch(() => {});
259
+ }
260
+ try {
261
+ for (const name of fs.readdirSync(evidenceDir).filter((name) => /^download-[A-Za-z0-9._-]+\.bin$/.test(name))) {
262
+ assertFileDoesNotExposeSecrets(path.join(evidenceDir, name), runtimeSecrets, profileId);
263
+ }
264
+ } catch (error) {
265
+ outcome = error instanceof QaStatusError ? error.status : "failed";
266
+ failure = error;
169
267
  }
170
268
  if (failure?.suppressEvidence) removeVisualEvidence(evidenceDir);
171
269
 
@@ -174,6 +272,9 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
174
272
  evidenceDir: relativePath(cwd, evidenceDir),
175
273
  evidence,
176
274
  artifacts: artifactManifest(evidenceDir, evidence),
275
+ viewport,
276
+ environment,
277
+ ...(observations.length > 0 ? { observations } : {}),
177
278
  };
178
279
  writeJsonPrivate(path.join(evidenceDir, "result.json"), {
179
280
  status: outcome,
@@ -197,7 +298,189 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
197
298
  writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
198
299
  }
199
300
 
200
- function readAuthConfig(cwd, required = false) {
301
+ async function runAuthScaffold(cwd, args) {
302
+ const profileId = args.profile;
303
+ if (!isSafeName(profileId) || profileId.length > 128) throw new QaStatusError("QA_RUN_FAILED", "--profile must be a safe profile id no longer than 128 characters", 1);
304
+ const loginUrl = normalizeScaffoldUrl(args.loginUrl, "--login-url");
305
+ const login = new URL(loginUrl);
306
+ const baseUrl = normalizeScaffoldUrl(args.baseUrl ?? `${login.origin}/`, "--base-url", login.origin);
307
+ const success = scaffoldSuccess(args);
308
+ const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
309
+ const progress = createRunnerProgress(path.join(agentDir, QA_WORKSPACE_RELATIVE));
310
+ assertAuthScaffoldWritable(cwd);
311
+ const timeout = Math.min(parseRunnerTimeout(args.runnerTimeoutMs, profileId), 60_000);
312
+ const deadline = Date.now() + timeout;
313
+ let browser;
314
+ let context;
315
+ try {
316
+ progress("auth_scaffold_started", { timeoutMs: timeout });
317
+ const playwright = loadPlaywright(cwd);
318
+ browser = await runStage(progress, "auth_scaffold_browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
319
+ context = await runStage(progress, "auth_scaffold_context_create", deadline, () => browser.newContext({
320
+ ...DEFAULT_ENVIRONMENT,
321
+ serviceWorkers: "block",
322
+ viewport: DEFAULT_VIEWPORT,
323
+ }), profileId);
324
+ await runStage(progress, "auth_scaffold_origin_guard", deadline, () => installOriginGuard(context, [login.origin], { type: "none" }), profileId);
325
+ const page = await runStage(progress, "auth_scaffold_page_create", deadline, () => context.newPage(), profileId);
326
+ await runStage(progress, "auth_scaffold_login_load", deadline, () => page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout }), profileId);
327
+ const discovered = await runStage(progress, "auth_scaffold_form_discovery", deadline, () => discoverLoginForm(page), profileId);
328
+ if (!success && !discovered.formSelector) {
329
+ throw new QaStatusError("QA_RUN_FAILED", "no login form was found; provide an explicit success condition for a form-less login page", 1, profileId);
330
+ }
331
+ const profile = {
332
+ description: args.description ?? `Browser QA form login (${profileId})`,
333
+ traits: ["auth:form"],
334
+ baseUrl,
335
+ allowedOrigins: [login.origin],
336
+ auth: {
337
+ type: "form",
338
+ loginUrl,
339
+ fields: discovered.fields.map((selector, index) => ({ selector, value: `__PI_QA_SECRET_${index + 1}__` })),
340
+ submitSelector: discovered.submitSelector,
341
+ success: success ?? { selector: discovered.formSelector, state: "hidden" },
342
+ },
343
+ };
344
+ const replacedEmptyTemplate = writeAuthScaffold(cwd, profileId, profile);
345
+ writeStatus({
346
+ status: "QA_AUTH_SCAFFOLD_CREATED",
347
+ file: CONFIG_RELATIVE,
348
+ profile: profileId,
349
+ action: "fill_credentials",
350
+ placeholderCount: discovered.fields.length,
351
+ replacedEmptyTemplate,
352
+ });
353
+ } catch (error) {
354
+ if (error instanceof QaStatusError) {
355
+ if (error.details?.timedOut) terminateRunnerDescendants();
356
+ throw error;
357
+ }
358
+ if (error instanceof RunnerStageTimeoutError) {
359
+ terminateRunnerDescendants();
360
+ throw new QaStatusError("QA_RUN_FAILED", "auth scaffold timed out; verify the public login page and retry", EXIT_RUNNER_TIMEOUT, profileId, { timedOut: true });
361
+ }
362
+ throw new QaStatusError("QA_RUN_FAILED", "auth scaffold failed; verify the public login page and retry", 1, profileId);
363
+ } finally {
364
+ if (context) await runCleanupStage(progress, "auth_scaffold_context_close", () => context.close()).catch(terminateOnCleanupTimeout);
365
+ if (browser) await runCleanupStage(progress, "auth_scaffold_browser_close", () => browser.close()).catch(terminateOnCleanupTimeout);
366
+ progress("auth_scaffold_finished");
367
+ }
368
+ }
369
+
370
+ function terminateOnCleanupTimeout(error) {
371
+ if (error instanceof RunnerStageTimeoutError) terminateRunnerDescendants();
372
+ }
373
+
374
+ function normalizeScaffoldUrl(raw, flag, expectedOrigin) {
375
+ if (typeof raw !== "string") throw new QaStatusError("QA_RUN_FAILED", `${flag} is required`, 1);
376
+ try {
377
+ const url = new URL(raw);
378
+ if (!isSecureScaffoldUrl(url) || url.username || url.password || (expectedOrigin && url.origin !== expectedOrigin)) throw new Error();
379
+ return url.href;
380
+ } catch {
381
+ throw new QaStatusError("QA_RUN_FAILED", `${flag} must use HTTPS or loopback HTTP without credentials${expectedOrigin ? " on the login origin" : ""}`, 1);
382
+ }
383
+ }
384
+
385
+ function isSecureScaffoldUrl(url) {
386
+ if (url.protocol === "https:") return true;
387
+ if (url.protocol !== "http:") return false;
388
+ const hostname = url.hostname.toLowerCase();
389
+ return hostname === "localhost"
390
+ || hostname.endsWith(".localhost")
391
+ || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
392
+ || hostname === "[::1]"
393
+ || hostname === "::1";
394
+ }
395
+
396
+ function scaffoldSuccess(args) {
397
+ if (args.successUrl && args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "use only one of --success-url or --success-selector", 1);
398
+ if (args.successState && !args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "--success-state requires --success-selector", 1);
399
+ if (args.successUrl) return { url: boundedScaffoldString(args.successUrl, "--success-url") };
400
+ if (!args.successSelector) return undefined;
401
+ const state = args.successState ?? "visible";
402
+ if (validLocatorState(state) !== state) throw new QaStatusError("QA_RUN_FAILED", "--success-state is invalid", 1);
403
+ return { selector: boundedScaffoldString(args.successSelector, "--success-selector"), state };
404
+ }
405
+
406
+ function boundedScaffoldString(value, flag) {
407
+ if (typeof value !== "string" || !value.trim() || value.length > 1024 || /[\r\n\0]/.test(value)) {
408
+ throw new QaStatusError("QA_RUN_FAILED", `${flag} must be a non-empty single-line string no longer than 1024 characters`, 1);
409
+ }
410
+ return value;
411
+ }
412
+
413
+ async function discoverLoginForm(page) {
414
+ const discovered = await page.evaluate(() => {
415
+ const quoteAttribute = (value) => value.replace(/[\0-\x1f\x7f"\\]/g, (character) => {
416
+ if (character === "\0") return "�";
417
+ if (character === '"') return '\\"';
418
+ if (character === "\\") return "\\\\";
419
+ return `\\${character.codePointAt(0).toString(16)} `;
420
+ });
421
+ const unique = (selector) => {
422
+ try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
423
+ };
424
+ const selectorFor = (element) => {
425
+ if (element.id) {
426
+ const selector = `[id="${quoteAttribute(element.id)}"]`;
427
+ if (unique(selector)) return selector;
428
+ }
429
+ for (const attribute of ["data-testid", "data-test", "name", "aria-label"]) {
430
+ const value = element.getAttribute(attribute);
431
+ if (!value) continue;
432
+ const selector = `${element.tagName.toLowerCase()}[${attribute}="${quoteAttribute(value)}"]`;
433
+ if (unique(selector)) return selector;
434
+ }
435
+ const parts = [];
436
+ let current = element;
437
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
438
+ const tag = current.tagName.toLowerCase();
439
+ const siblings = [...current.parentElement.children].filter((item) => item.tagName === current.tagName);
440
+ parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
441
+ const selector = parts.join(" > ");
442
+ if (unique(selector)) return selector;
443
+ current = current.parentElement;
444
+ }
445
+ return parts.join(" > ");
446
+ };
447
+ const fillable = (element) => {
448
+ if (element.disabled || element.getAttribute("aria-hidden") === "true") return false;
449
+ if (element instanceof HTMLTextAreaElement) return true;
450
+ if (!(element instanceof HTMLInputElement)) return false;
451
+ return ["email", "password", "text", "tel", "url", "search", "number"].includes((element.type || "text").toLowerCase());
452
+ };
453
+ const visible = (element) => Boolean(element.getClientRects().length) && getComputedStyle(element).visibility !== "hidden";
454
+ const forms = [...document.querySelectorAll("form")];
455
+ const ranked = forms.map((form) => {
456
+ const fields = [...form.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
457
+ const passwordCount = fields.filter((element) => element instanceof HTMLInputElement && element.type === "password").length;
458
+ return { form, fields, score: passwordCount * 100 + fields.length };
459
+ }).filter((entry) => entry.fields.length > 0).sort((left, right) => right.score - left.score);
460
+ const selected = ranked[0];
461
+ const root = selected?.form ?? document;
462
+ const fields = selected?.fields ?? [...document.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
463
+ const submit = [...root.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')].find(visible);
464
+ return {
465
+ fields: fields.map(selectorFor),
466
+ submitSelector: submit ? selectorFor(submit) : undefined,
467
+ formSelector: selected ? selectorFor(selected.form) : undefined,
468
+ };
469
+ });
470
+ if (!isObject(discovered) || !Array.isArray(discovered.fields) || discovered.fields.length === 0
471
+ || discovered.fields.length > 10 || !discovered.fields.every(isBoundedSelector)
472
+ || !isBoundedSelector(discovered.submitSelector)
473
+ || (discovered.formSelector !== undefined && !isBoundedSelector(discovered.formSelector))) {
474
+ throw new Error("no supported login form with fillable fields and a submit control was found");
475
+ }
476
+ return discovered;
477
+ }
478
+
479
+ function isBoundedSelector(value) {
480
+ return typeof value === "string" && value.length > 0 && value.length <= 1024 && !/[\0-\x1f\x7f]/.test(value);
481
+ }
482
+
483
+ function readAuthConfig(cwd, required = false, allowPlaceholders = false) {
201
484
  const candidate = path.join(cwd, CONFIG_RELATIVE);
202
485
  if (!fs.existsSync(candidate)) {
203
486
  if (!required) return { present: false, profiles: {} };
@@ -240,6 +523,18 @@ function readAuthConfig(cwd, required = false) {
240
523
  { action: "provide_credentials", templateCreated: false },
241
524
  );
242
525
  }
526
+ const profileValues = Object.values(value.profiles);
527
+ if (required && !allowPlaceholders && profileValues.length > 0
528
+ && profileValues.every((profile) => authPlaceholderCount(profile) > 0)) {
529
+ const placeholderCount = profileValues.reduce((count, profile) => count + authPlaceholderCount(profile), 0);
530
+ throw new QaStatusError(
531
+ "QA_AUTH_UPDATE_REQUIRED",
532
+ `credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
533
+ EXIT_AUTH_UPDATE_REQUIRED,
534
+ undefined,
535
+ { action: "fill_credentials", templateCreated: false, placeholderCount },
536
+ );
537
+ }
243
538
  if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
244
539
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
245
540
  }
@@ -264,9 +559,28 @@ function selectProfile(profiles, requestedId) {
264
559
  if (!isObject(profile.auth) || typeof profile.auth.type !== "string") {
265
560
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile auth configuration is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
266
561
  }
562
+ const placeholderCount = authPlaceholderCount(profile);
563
+ if (placeholderCount > 0) {
564
+ throw new QaStatusError(
565
+ "QA_AUTH_UPDATE_REQUIRED",
566
+ `credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
567
+ EXIT_AUTH_UPDATE_REQUIRED,
568
+ requestedId,
569
+ { action: "fill_credentials", templateCreated: false, placeholderCount },
570
+ );
571
+ }
267
572
  return { id: requestedId, profile };
268
573
  }
269
574
 
575
+ function authPlaceholderCount(profile) {
576
+ if (!isObject(profile) || !isObject(profile.auth) || profile.auth.type !== "form" || !Array.isArray(profile.auth.fields)) return 0;
577
+ return profile.auth.fields.filter((field) => (
578
+ isObject(field)
579
+ && typeof field.value === "string"
580
+ && AUTH_SECRET_PLACEHOLDER.test(field.value.trim())
581
+ )).length;
582
+ }
583
+
270
584
  function createPublicProfile(rawBaseUrl) {
271
585
  if (typeof rawBaseUrl !== "string") {
272
586
  throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
@@ -343,6 +657,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
343
657
  if (!Array.isArray(auth.fields) || auth.fields.length === 0 || auth.fields.some((field) => !isObject(field) || typeof field.selector !== "string" || !field.selector || typeof field.value !== "string" || !field.value)) throw authError(profileId, "form fields require non-empty selector/value strings");
344
658
  if (typeof auth.submitSelector !== "string" || !auth.submitSelector) throw authError(profileId, "form submitSelector is missing");
345
659
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) throw authError(profileId, "form success.url or success.selector is required");
660
+ if (auth.success.state !== undefined && (typeof auth.success.selector !== "string" || validLocatorState(auth.success.state) !== auth.success.state)) throw authError(profileId, "form success.state requires a selector and valid locator state");
346
661
  break;
347
662
  case "storageState":
348
663
  if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
@@ -354,7 +669,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
354
669
 
355
670
  function readFlow(flowPath, profileId) {
356
671
  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);
672
+ 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
673
  const errors = [];
359
674
  const flow = parseJsonc(fs.readFileSync(flowPath, "utf8"), errors, { allowTrailingComma: true });
360
675
  if (errors.length > 0 || !isObject(flow) || !Array.isArray(flow.steps) || flow.steps.length === 0 || flow.steps.length > 100) {
@@ -363,26 +678,109 @@ function readFlow(flowPath, profileId) {
363
678
  return flow;
364
679
  }
365
680
 
366
- async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets }) {
681
+ function resolveViewport(flow, profileId) {
682
+ if (flow.viewport === undefined) return { ...DEFAULT_VIEWPORT };
683
+ if (!isObject(flow.viewport)) throw new QaStatusError("QA_RUN_FAILED", "QA flow viewport must be an object with width and height", 1, profileId);
684
+ const { width, height } = flow.viewport;
685
+ if (!Number.isInteger(width) || !Number.isInteger(height)
686
+ || width < MIN_VIEWPORT.width || height < MIN_VIEWPORT.height
687
+ || width > MAX_VIEWPORT.width || height > MAX_VIEWPORT.height) {
688
+ throw new QaStatusError(
689
+ "QA_RUN_FAILED",
690
+ `QA flow viewport must use integer dimensions from ${MIN_VIEWPORT.width}x${MIN_VIEWPORT.height} through ${MAX_VIEWPORT.width}x${MAX_VIEWPORT.height}`,
691
+ 1,
692
+ profileId,
693
+ );
694
+ }
695
+ return { width, height };
696
+ }
697
+
698
+ function resolveEnvironment(flow, profileId) {
699
+ if (flow.environment === undefined) return { ...DEFAULT_ENVIRONMENT };
700
+ if (!isObject(flow.environment)) throw new QaStatusError("QA_RUN_FAILED", "QA flow environment must be an object", 1, profileId);
701
+ const environment = { ...DEFAULT_ENVIRONMENT };
702
+ if (flow.environment.locale !== undefined) {
703
+ if (typeof flow.environment.locale !== "string" || flow.environment.locale.length > 64) {
704
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment locale is invalid", 1, profileId);
705
+ }
706
+ try {
707
+ environment.locale = Intl.getCanonicalLocales(flow.environment.locale)[0];
708
+ } catch {
709
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment locale is invalid", 1, profileId);
710
+ }
711
+ }
712
+ if (flow.environment.timezoneId !== undefined) {
713
+ if (typeof flow.environment.timezoneId !== "string" || flow.environment.timezoneId.length > 100) {
714
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment timezoneId is invalid", 1, profileId);
715
+ }
716
+ try {
717
+ new Intl.DateTimeFormat("en", { timeZone: flow.environment.timezoneId }).format(0);
718
+ environment.timezoneId = flow.environment.timezoneId;
719
+ } catch {
720
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment timezoneId is invalid", 1, profileId);
721
+ }
722
+ }
723
+ if (flow.environment.colorScheme !== undefined) {
724
+ if (!["light", "dark", "no-preference"].includes(flow.environment.colorScheme)) {
725
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment colorScheme is invalid", 1, profileId);
726
+ }
727
+ environment.colorScheme = flow.environment.colorScheme;
728
+ }
729
+ if (flow.environment.reducedMotion !== undefined) {
730
+ if (!["reduce", "no-preference"].includes(flow.environment.reducedMotion)) {
731
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow environment reducedMotion is invalid", 1, profileId);
732
+ }
733
+ environment.reducedMotion = flow.environment.reducedMotion;
734
+ }
735
+ return environment;
736
+ }
737
+
738
+ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets, observations, videos }) {
367
739
  const timeout = Math.min(finitePositive(flow.timeoutMs) ?? 15_000, 60_000);
368
740
  page.setDefaultTimeout(timeout);
369
741
  page.setDefaultNavigationTimeout(timeout);
742
+ const popups = new Map();
743
+ let activeIndex = 0;
744
+ let popupCaptureArmed = false;
745
+ let capturedPopup;
746
+ let unexpectedPopupError;
747
+ const onContextPage = (candidate) => {
748
+ if (candidate === page) return;
749
+ if (popupCaptureArmed && !capturedPopup) {
750
+ capturedPopup = candidate;
751
+ return;
752
+ }
753
+ videos.push({ name: `discarded-popup-${videos.length}.webm`, video: candidate.video(), discard: true });
754
+ unexpectedPopupError ??= flowError(profileId, activeIndex, "unexpected popup opened; use openPopup to declare it");
755
+ void candidate.close().catch(() => {});
756
+ };
757
+ context.on("page", onContextPage);
758
+ try {
370
759
  for (let index = 0; index < flow.steps.length; index += 1) {
760
+ activeIndex = index;
371
761
  const step = flow.steps[index];
372
762
  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);
763
+ if ((step.expectResponse !== undefined || step.expectDialog !== undefined)
764
+ && !["click", "doubleClick", "press", "check", "uncheck", "selectOption"].includes(step.action)) {
765
+ throw flowError(profileId, index, "response and dialog expectations require a triggering interaction");
766
+ }
767
+ const { scope, ownerPage } = await resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index });
768
+ const locator = step.locator === undefined ? undefined : resolveLocator(scope, step.locator, profileId, index);
374
769
  switch (step.action) {
375
770
  case "goto": {
376
771
  const target = typeof step.url === "string" ? step.url : step.path;
377
772
  if (typeof target !== "string") throw flowError(profileId, index, "goto requires url or path");
378
773
  const url = new URL(target, baseURL);
379
774
  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) });
775
+ await scope.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
381
776
  break;
382
777
  }
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;
778
+ case "reload":
779
+ if (scope !== ownerPage) throw flowError(profileId, index, "reload is not supported for frame targets");
780
+ await ownerPage.reload({ waitUntil: validWaitUntil(step.waitUntil) });
781
+ break;
782
+ case "click": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).click() }); break;
783
+ case "doubleClick": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).dblclick() }); break;
386
784
  case "hover": await requireLocator(locator, profileId, index).hover(); break;
387
785
  case "fill":
388
786
  if (typeof step.value !== "string") throw flowError(profileId, index, "fill requires a string value");
@@ -390,42 +788,176 @@ async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, p
390
788
  break;
391
789
  case "press":
392
790
  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);
791
+ await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).press(step.key) });
394
792
  break;
395
- case "check": await requireLocator(locator, profileId, index).check(); break;
396
- case "uncheck": await requireLocator(locator, profileId, index).uncheck(); break;
793
+ case "check": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).check() }); break;
794
+ case "uncheck": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).uncheck() }); break;
397
795
  case "selectOption":
398
796
  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);
797
+ await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).selectOption(step.value) });
798
+ break;
799
+ case "dragTo": {
800
+ const destination = resolveLocator(scope, step.dropTarget, profileId, index);
801
+ const options = {};
802
+ if (step.sourcePosition !== undefined) options.sourcePosition = resolvePoint(step.sourcePosition, profileId, index, "sourcePosition");
803
+ if (step.dropPosition !== undefined) options.targetPosition = resolvePoint(step.dropPosition, profileId, index, "dropPosition");
804
+ await requireLocator(locator, profileId, index).dragTo(destination, options);
805
+ break;
806
+ }
807
+ case "uploadFiles": {
808
+ const files = decodeUploadFiles(step.files, profileId, index);
809
+ await requireLocator(locator, profileId, index).setInputFiles(files);
810
+ break;
811
+ }
812
+ case "openPopup": {
813
+ if (!isSafeName(step.name)) throw flowError(profileId, index, "openPopup requires a safe name");
814
+ if (popups.has(step.name)) throw flowError(profileId, index, `popup name is duplicated: ${step.name}`);
815
+ if (popups.size >= MAX_POPUPS) throw flowError(profileId, index, `openPopup supports at most ${MAX_POPUPS} popups`);
816
+ popupCaptureArmed = true;
817
+ capturedPopup = undefined;
818
+ const popupPromise = ownerPage.waitForEvent("popup", { timeout });
819
+ let popup;
820
+ let popupVideo;
821
+ try {
822
+ [, popup] = await Promise.all([requireLocator(locator, profileId, index).click(), popupPromise]);
823
+ popup.setDefaultTimeout(timeout);
824
+ popup.setDefaultNavigationTimeout(timeout);
825
+ popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: false };
826
+ videos.push(popupVideo);
827
+ await popup.waitForLoadState("domcontentloaded", { timeout });
828
+ const origin = assertSameOriginPage(popup, ownerPage, allowedOrigins, profileId, index, "popup");
829
+ await popup.bringToFront();
830
+ // A static popup may not repaint after Playwright starts its screencast, leaving the video blank.
831
+ await popup.screenshot({ type: "png" });
832
+ await popup.waitForTimeout(POPUP_VIDEO_SETTLE_MS);
833
+ popups.set(step.name, { page: popup, origin });
834
+ } catch (error) {
835
+ popup ??= capturedPopup;
836
+ if (popup && !popupVideo) {
837
+ popupVideo = { name: `discarded-popup-${videos.length}.webm`, video: popup.video(), discard: true };
838
+ videos.push(popupVideo);
839
+ }
840
+ if (popupVideo) popupVideo.discard = true;
841
+ await popup?.close().catch(() => {});
842
+ throw error;
843
+ } finally {
844
+ popupCaptureArmed = false;
845
+ capturedPopup = undefined;
846
+ }
847
+ break;
848
+ }
849
+ case "download": {
850
+ await executeDownload({ ownerPage, locator: requireLocator(locator, profileId, index), step, evidenceDir, allowedOrigins, timeout, profileId, index, secrets });
851
+ break;
852
+ }
853
+ case "wheel": {
854
+ const deltaX = optionalFiniteNumber(step.deltaX, 0, profileId, index, "wheel deltaX");
855
+ const deltaY = optionalFiniteNumber(step.deltaY, 0, profileId, index, "wheel deltaY");
856
+ if (deltaX === 0 && deltaY === 0) throw flowError(profileId, index, "wheel requires a non-zero deltaX or deltaY");
857
+ if (locator) await locator.hover();
858
+ await ownerPage.mouse.wheel(deltaX, deltaY);
859
+ break;
860
+ }
861
+ case "evaluate":
862
+ await executeSafeEvaluation({ page: scope, locator, step, profileId, index, observations });
400
863
  break;
401
864
  case "waitFor": await requireLocator(locator, profileId, index).waitFor({ state: validLocatorState(step.state) }); break;
402
865
  case "waitForTimeout":
403
866
  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);
867
+ await ownerPage.waitForTimeout(step.timeoutMs);
868
+ break;
869
+ case "assertVisible": {
870
+ const target = requireLocator(locator, profileId, index);
871
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be visible", check: () => target.isVisible() });
872
+ break;
873
+ }
874
+ case "assertHidden": {
875
+ const target = requireLocator(locator, profileId, index);
876
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be hidden", check: () => target.isHidden() });
877
+ break;
878
+ }
879
+ case "assertEnabled": {
880
+ const target = requireLocator(locator, profileId, index);
881
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be enabled", check: () => target.isEnabled() });
882
+ break;
883
+ }
884
+ case "assertDisabled": {
885
+ const target = requireLocator(locator, profileId, index);
886
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be disabled", check: () => target.isDisabled() });
887
+ break;
888
+ }
889
+ case "assertChecked": {
890
+ const target = requireLocator(locator, profileId, index);
891
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be checked", check: () => target.isChecked() });
892
+ break;
893
+ }
894
+ case "assertUnchecked": {
895
+ const target = requireLocator(locator, profileId, index);
896
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "expected locator to be unchecked", check: async () => !(await target.isChecked()) });
405
897
  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;
898
+ }
412
899
  case "assertText": {
413
- const actual = (await requireLocator(locator, profileId, index).textContent()) ?? "";
414
- assertExpectedString(actual, step, profileId, index, "text");
900
+ const matches = expectedStringMatcher(step, profileId, index, "text");
901
+ const target = requireLocator(locator, profileId, index);
902
+ await retryAssertion({
903
+ page: ownerPage, timeout, profileId, index, reason: "text assertion failed",
904
+ check: async () => matches((await target.textContent()) ?? ""),
905
+ });
415
906
  break;
416
907
  }
417
908
  case "assertValue": {
418
- const actual = await requireLocator(locator, profileId, index).inputValue();
419
- assertExpectedString(actual, step, profileId, index, "value");
909
+ const matches = expectedStringMatcher(step, profileId, index, "value");
910
+ const target = requireLocator(locator, profileId, index);
911
+ await retryAssertion({
912
+ page: ownerPage, timeout, profileId, index, reason: "value assertion failed",
913
+ check: async () => matches(await target.inputValue()),
914
+ });
915
+ break;
916
+ }
917
+ case "assertAttribute": {
918
+ if (typeof step.attribute !== "string" || !ATTRIBUTE_NAME.test(step.attribute)) {
919
+ throw flowError(profileId, index, "assertAttribute requires a valid attribute name");
920
+ }
921
+ const matches = expectedStringMatcher(step, profileId, index, "attribute");
922
+ const target = requireLocator(locator, profileId, index);
923
+ await retryAssertion({
924
+ page: ownerPage, timeout, profileId, index, reason: "attribute assertion failed",
925
+ check: async () => {
926
+ const actual = await target.getAttribute(step.attribute);
927
+ return actual !== null && matches(actual);
928
+ },
929
+ });
420
930
  break;
421
931
  }
422
- case "assertCount":
932
+ case "assertCount": {
423
933
  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");
934
+ const target = requireLocator(locator, profileId, index);
935
+ await retryAssertion({
936
+ page: ownerPage, timeout, profileId, index, reason: "locator count assertion failed",
937
+ check: async () => (await target.count()) === step.equals,
938
+ });
425
939
  break;
426
- case "assertURL": assertExpectedString(page.url(), step, profileId, index, "URL"); break;
940
+ }
941
+ case "assertDOMMetric": {
942
+ if (typeof step.metric !== "string") throw flowError(profileId, index, "assertDOMMetric requires metric");
943
+ const initialMetrics = await collectDomMetrics(scope, locator);
944
+ if (!Object.prototype.hasOwnProperty.call(initialMetrics, step.metric)) {
945
+ throw flowError(profileId, index, `unsupported DOM metric: ${step.metric}`);
946
+ }
947
+ const matches = numericComparisonMatcher(step, profileId, index);
948
+ await retryAssertion({
949
+ page: ownerPage, timeout, profileId, index, reason: `${step.metric} assertion failed`,
950
+ check: async () => matches((await collectDomMetrics(scope, locator))[step.metric]),
951
+ });
952
+ break;
953
+ }
954
+ case "assertURL": {
955
+ const matches = expectedStringMatcher(step, profileId, index, "URL");
956
+ await retryAssertion({ page: ownerPage, timeout, profileId, index, reason: "URL assertion failed", check: () => matches(scope.url()) });
957
+ break;
958
+ }
427
959
  case "authRejectedIf": {
428
- const urlRejected = typeof step.urlIncludes === "string" && page.url().includes(step.urlIncludes);
960
+ const urlRejected = typeof step.urlIncludes === "string" && scope.url().includes(step.urlIncludes);
429
961
  const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
430
962
  if (!urlRejected && !locatorRejected) break;
431
963
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "application rejected or expired the configured authentication", EXIT_AUTH_UPDATE_REQUIRED, profileId);
@@ -433,13 +965,377 @@ async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, p
433
965
  case "screenshot": {
434
966
  const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
435
967
  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 });
968
+ await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
969
+ await ownerPage.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
438
970
  break;
439
971
  }
440
972
  default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
441
973
  }
442
- await assertPageDoesNotExposeSecrets(page, secrets, profileId);
974
+ if (unexpectedPopupError) throw unexpectedPopupError;
975
+ await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
976
+ }
977
+ } finally {
978
+ context.off("page", onContextPage);
979
+ }
980
+ }
981
+
982
+ async function resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index }) {
983
+ if (step.target === undefined || (isObject(step.target) && step.target.type === "main")) {
984
+ return { scope: page, ownerPage: page };
985
+ }
986
+ if (!isObject(step.target)) throw flowError(profileId, index, "target must be an object");
987
+ if (step.target.type === "popup") {
988
+ if (!isSafeName(step.target.name) || !popups.has(step.target.name)) throw flowError(profileId, index, "popup target is missing or unknown");
989
+ const record = popups.get(step.target.name);
990
+ const popup = record.page;
991
+ if (popup.isClosed()) throw flowError(profileId, index, "popup target is closed");
992
+ assertPageOrigin(popup, record.origin, allowedOrigins, profileId, index, "popup target");
993
+ return { scope: popup, ownerPage: popup };
994
+ }
995
+ if (step.target.type === "frame") {
996
+ const popupRecord = step.target.page === undefined ? undefined : popups.get(step.target.page);
997
+ const ownerPage = step.target.page === undefined ? page : popupRecord?.page;
998
+ if (!ownerPage || ownerPage.isClosed() || (step.target.page !== undefined && !isSafeName(step.target.page))) {
999
+ throw flowError(profileId, index, "frame target page is missing or unknown");
1000
+ }
1001
+ const frameLocator = resolveLocator(ownerPage, step.target.locator, profileId, index);
1002
+ const handle = await frameLocator.elementHandle();
1003
+ const frame = await handle?.contentFrame();
1004
+ if (!frame) throw flowError(profileId, index, "frame target did not resolve to an iframe");
1005
+ let origin;
1006
+ try { origin = await frame.evaluate(() => location.origin); } catch { /* rejected below */ }
1007
+ let ownerOrigin;
1008
+ try { ownerOrigin = new URL(ownerPage.url()).origin; } catch { /* rejected below */ }
1009
+ if (!allowedOrigins.includes(origin) || origin !== ownerOrigin) throw flowError(profileId, index, "frame target must be same-origin with its page");
1010
+ return { scope: frame, ownerPage };
1011
+ }
1012
+ throw flowError(profileId, index, "target type must be main, popup, or frame");
1013
+ }
1014
+
1015
+ function assertSameOriginPage(page, ownerPage, allowedOrigins, profileId, index, label) {
1016
+ let ownerOrigin;
1017
+ try { ownerOrigin = new URL(ownerPage.url()).origin; } catch { /* rejected below */ }
1018
+ return assertPageOrigin(page, ownerOrigin, allowedOrigins, profileId, index, label);
1019
+ }
1020
+
1021
+ function assertPageOrigin(page, expectedOrigin, allowedOrigins, profileId, index, label) {
1022
+ try {
1023
+ const url = new URL(page.url());
1024
+ if (!url.username && !url.password && allowedOrigins.includes(url.origin) && url.origin === expectedOrigin) return url.origin;
1025
+ } catch { /* rejected below */ }
1026
+ throw flowError(profileId, index, `${label} must be same-origin with its opener`);
1027
+ }
1028
+
1029
+ async function executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action }) {
1030
+ const watchers = [];
1031
+ try {
1032
+ if (step.expectResponse !== undefined) watchers.push(createResponseWatcher(ownerPage, step.expectResponse, allowedOrigins, timeout, profileId, index));
1033
+ if (step.expectDialog !== undefined) watchers.push(createDialogWatcher(ownerPage, step.expectDialog, timeout, profileId, index));
1034
+ await Promise.all([action(), ...watchers.map((watcher) => watcher.promise)]);
1035
+ } finally {
1036
+ for (const watcher of watchers) watcher.cancel();
1037
+ }
1038
+ }
1039
+
1040
+ function createResponseWatcher(page, expectation, allowedOrigins, timeout, profileId, index) {
1041
+ if (!isObject(expectation)
1042
+ || typeof expectation.path !== "string"
1043
+ || !expectation.path.startsWith("/")
1044
+ || expectation.path.startsWith("//")
1045
+ || /[?#\\]/.test(expectation.path)
1046
+ || !HTTP_METHODS.has(expectation.method)
1047
+ || !Number.isInteger(expectation.status)
1048
+ || expectation.status < 100
1049
+ || expectation.status > 599) {
1050
+ throw flowError(profileId, index, "expectResponse requires exact path, method, and status");
1051
+ }
1052
+ let settled = false;
1053
+ let settle;
1054
+ const matchingRequests = new Set();
1055
+ const promise = new Promise((resolve, reject) => {
1056
+ settle = { resolve, reject };
1057
+ });
1058
+ const cleanup = () => {
1059
+ clearTimeout(timer);
1060
+ page.off("request", onRequest);
1061
+ page.off("response", onResponse);
1062
+ };
1063
+ const finish = (error) => {
1064
+ if (settled) return;
1065
+ settled = true;
1066
+ cleanup();
1067
+ if (error) settle.reject(error);
1068
+ else settle.resolve();
1069
+ };
1070
+ const onRequest = (request) => {
1071
+ try {
1072
+ const url = new URL(request.url());
1073
+ if (!allowedOrigins.includes(url.origin)
1074
+ || url.pathname !== expectation.path
1075
+ || request.method() !== expectation.method) return;
1076
+ matchingRequests.add(request);
1077
+ } catch { /* unrelated request */ }
1078
+ };
1079
+ const onResponse = (response) => {
1080
+ try {
1081
+ if (!matchingRequests.has(response.request()) || response.status() !== expectation.status) return;
1082
+ finish();
1083
+ } catch { /* unrelated response */ }
1084
+ };
1085
+ const timer = setTimeout(() => finish(flowError(profileId, index, "expected response was not observed before timeout")), timeout);
1086
+ page.on("request", onRequest);
1087
+ page.on("response", onResponse);
1088
+ return { promise, cancel: () => finish() };
1089
+ }
1090
+
1091
+ function createDialogWatcher(page, expectation, timeout, profileId, index) {
1092
+ if (!isObject(expectation)
1093
+ || !DIALOG_TYPES.has(expectation.type)
1094
+ || !isObject(expectation.message)
1095
+ || typeof expectation.accept !== "boolean") {
1096
+ throw flowError(profileId, index, "expectDialog requires type, message matcher, and accept");
1097
+ }
1098
+ const matches = expectedStringMatcher(expectation.message, profileId, index, "dialog message");
1099
+ let settled = false;
1100
+ let settle;
1101
+ const promise = new Promise((resolve, reject) => {
1102
+ settle = { resolve, reject };
1103
+ });
1104
+ const cleanup = () => {
1105
+ clearTimeout(timer);
1106
+ page.off("dialog", onDialog);
1107
+ };
1108
+ const finish = (error) => {
1109
+ if (settled) return;
1110
+ settled = true;
1111
+ cleanup();
1112
+ if (error) settle.reject(error);
1113
+ else settle.resolve();
1114
+ };
1115
+ const onDialog = async (dialog) => {
1116
+ const matched = dialog.type() === expectation.type && matches(dialog.message());
1117
+ try {
1118
+ if (matched && expectation.accept) await dialog.accept();
1119
+ else await dialog.dismiss();
1120
+ finish(matched ? undefined : flowError(profileId, index, "native dialog did not match expectation"));
1121
+ } catch {
1122
+ finish(flowError(profileId, index, "native dialog could not be handled"));
1123
+ }
1124
+ };
1125
+ const timer = setTimeout(() => finish(flowError(profileId, index, "expected native dialog was not observed before timeout")), timeout);
1126
+ page.on("dialog", onDialog);
1127
+ return { promise, cancel: () => finish() };
1128
+ }
1129
+
1130
+ function resolvePoint(value, profileId, index, label) {
1131
+ if (!isObject(value)
1132
+ || typeof value.x !== "number" || !Number.isFinite(value.x) || value.x < 0 || value.x > 100_000
1133
+ || typeof value.y !== "number" || !Number.isFinite(value.y) || value.y < 0 || value.y > 100_000) {
1134
+ throw flowError(profileId, index, `${label} requires bounded non-negative x and y`);
1135
+ }
1136
+ return { x: value.x, y: value.y };
1137
+ }
1138
+
1139
+ function decodeUploadFiles(value, profileId, index) {
1140
+ if (!Array.isArray(value) || value.length > MAX_UPLOAD_FILES) {
1141
+ throw flowError(profileId, index, `uploadFiles requires an array of at most ${MAX_UPLOAD_FILES} files`);
1142
+ }
1143
+ let totalBytes = 0;
1144
+ return value.map((file) => {
1145
+ if (!isObject(file)
1146
+ || typeof file.name !== "string" || file.name.length === 0 || file.name.length > 128 || /[\\/\0-\x1f]/.test(file.name)
1147
+ || typeof file.mimeType !== "string" || file.mimeType.length > 128 || !/^[\w!#$&^_.+-]+\/[\w!#$&^_.+-]+$/.test(file.mimeType)
1148
+ || typeof file.base64 !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(file.base64)) {
1149
+ throw flowError(profileId, index, "uploadFiles entries require a safe name, MIME type, and canonical base64");
1150
+ }
1151
+ const buffer = Buffer.from(file.base64, "base64");
1152
+ if (buffer.toString("base64") !== file.base64 || buffer.length > MAX_UPLOAD_FILE_BYTES) {
1153
+ throw flowError(profileId, index, `uploadFiles entries may not exceed ${MAX_UPLOAD_FILE_BYTES} bytes`);
1154
+ }
1155
+ totalBytes += buffer.length;
1156
+ if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) throw flowError(profileId, index, `uploadFiles total may not exceed ${MAX_UPLOAD_TOTAL_BYTES} bytes`);
1157
+ return { name: file.name, mimeType: file.mimeType, buffer };
1158
+ });
1159
+ }
1160
+
1161
+ async function executeDownload({ ownerPage, locator, step, evidenceDir, allowedOrigins, timeout, profileId, index, secrets }) {
1162
+ if (!isObject(step.filename)) throw flowError(profileId, index, "download requires a filename matcher");
1163
+ const filenameMatches = expectedStringMatcher(step.filename, profileId, index, "download filename");
1164
+ const maxBytes = step.maxBytes === undefined ? DEFAULT_DOWNLOAD_MAX_BYTES : step.maxBytes;
1165
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) {
1166
+ throw flowError(profileId, index, `download maxBytes must be between 1 and ${MAX_DOWNLOAD_BYTES}`);
1167
+ }
1168
+ if (step.retain !== undefined && typeof step.retain !== "boolean") throw flowError(profileId, index, "download retain must be boolean");
1169
+ if (step.retain === true && !isSafeName(step.name)) throw flowError(profileId, index, "retained download requires a safe name");
1170
+ const retained = step.retain === true ? path.join(evidenceDir, `download-${step.name}.bin`) : undefined;
1171
+ if (retained && fs.existsSync(retained)) throw flowError(profileId, index, `retained download name is duplicated: ${step.name}`);
1172
+ const downloadPromise = ownerPage.waitForEvent("download", { timeout });
1173
+ const [, download] = await Promise.all([locator.click(), downloadPromise]);
1174
+ try {
1175
+ const url = new URL(download.url());
1176
+ if (!allowedOrigins.includes(url.origin)) throw flowError(profileId, index, "download is outside allowedOrigins");
1177
+ } catch (error) {
1178
+ await download.cancel().catch(() => {});
1179
+ if (error instanceof QaStatusError) throw error;
1180
+ throw flowError(profileId, index, "download is outside allowedOrigins");
1181
+ }
1182
+ if (!filenameMatches(download.suggestedFilename())) {
1183
+ await download.cancel().catch(() => {});
1184
+ throw flowError(profileId, index, "download filename did not match expectation");
1185
+ }
1186
+ const temporary = path.join(evidenceDir, `.download-${index + 1}.tmp`);
1187
+ try {
1188
+ await saveDownloadWithLimit(download, temporary, maxBytes, profileId, index);
1189
+ assertFileDoesNotExposeSecrets(temporary, secrets, profileId);
1190
+ if (retained) {
1191
+ fs.renameSync(temporary, retained);
1192
+ fs.chmodSync(retained, 0o600);
1193
+ }
1194
+ } finally {
1195
+ fs.rmSync(temporary, { force: true });
1196
+ }
1197
+ }
1198
+
1199
+ async function saveDownloadWithLimit(download, destination, maxBytes, profileId, index) {
1200
+ let completed = false;
1201
+ let failure;
1202
+ const save = download.saveAs(destination).then(
1203
+ () => { completed = true; },
1204
+ (error) => { completed = true; failure = error; },
1205
+ );
1206
+ let exceeded = false;
1207
+ while (!completed) {
1208
+ await new Promise((resolve) => setTimeout(resolve, 10));
1209
+ if (!fs.existsSync(destination) || fs.statSync(destination).size <= maxBytes) continue;
1210
+ exceeded = true;
1211
+ await download.cancel().catch(() => {});
1212
+ break;
1213
+ }
1214
+ await save;
1215
+ if (exceeded || (fs.existsSync(destination) && fs.statSync(destination).size > maxBytes)) {
1216
+ throw flowError(profileId, index, "download exceeded maxBytes");
1217
+ }
1218
+ if (failure) throw flowError(profileId, index, "download could not be saved");
1219
+ }
1220
+
1221
+ function assertFileDoesNotExposeSecrets(file, secrets, profileId) {
1222
+ if (secrets.length === 0) return;
1223
+ const bytes = fs.readFileSync(file);
1224
+ if (!secrets.some((secret) => secret && bytes.includes(Buffer.from(secret)))) return;
1225
+ const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in a retained download; retained evidence was discarded", 1, profileId);
1226
+ error.suppressEvidence = true;
1227
+ throw error;
1228
+ }
1229
+
1230
+ async function executeSafeEvaluation({ page, locator, step, profileId, index, observations }) {
1231
+ switch (step.operation) {
1232
+ case "scrollTo": {
1233
+ const position = {
1234
+ x: scrollPosition(step.x, profileId, index, "evaluate scrollTo x"),
1235
+ y: scrollPosition(step.y, profileId, index, "evaluate scrollTo y"),
1236
+ };
1237
+ if (position.x === undefined && position.y === undefined) throw flowError(profileId, index, "evaluate scrollTo requires x or y");
1238
+ if (locator) {
1239
+ await locator.evaluate((element, next) => {
1240
+ const maxX = Math.max(0, element.scrollWidth - element.clientWidth);
1241
+ const maxY = Math.max(0, element.scrollHeight - element.clientHeight);
1242
+ const x = next.x === "max" ? maxX : next.x ?? element.scrollLeft;
1243
+ const y = next.y === "max" ? maxY : next.y ?? element.scrollTop;
1244
+ element.scrollTo(x, y);
1245
+ }, position);
1246
+ } else {
1247
+ await page.evaluate((next) => {
1248
+ const root = document.scrollingElement ?? document.documentElement;
1249
+ const maxX = Math.max(0, root.scrollWidth - window.innerWidth);
1250
+ const maxY = Math.max(0, root.scrollHeight - window.innerHeight);
1251
+ const x = next.x === "max" ? maxX : next.x ?? window.scrollX;
1252
+ const y = next.y === "max" ? maxY : next.y ?? window.scrollY;
1253
+ window.scrollTo(x, y);
1254
+ }, position);
1255
+ }
1256
+ break;
1257
+ }
1258
+ case "scrollBy": {
1259
+ const deltaX = optionalFiniteNumber(step.deltaX, 0, profileId, index, "evaluate scrollBy deltaX");
1260
+ const deltaY = optionalFiniteNumber(step.deltaY, 0, profileId, index, "evaluate scrollBy deltaY");
1261
+ if (deltaX === 0 && deltaY === 0) throw flowError(profileId, index, "evaluate scrollBy requires a non-zero deltaX or deltaY");
1262
+ if (locator) await locator.evaluate((element, delta) => element.scrollBy(delta.x, delta.y), { x: deltaX, y: deltaY });
1263
+ else await page.evaluate((delta) => window.scrollBy(delta.x, delta.y), { x: deltaX, y: deltaY });
1264
+ break;
1265
+ }
1266
+ case "metrics": {
1267
+ const name = step.name === undefined ? `metrics-${index + 1}` : step.name;
1268
+ if (!isSafeName(name)) throw flowError(profileId, index, "evaluate metrics name is invalid");
1269
+ if (observations.some((entry) => entry.name === name)) throw flowError(profileId, index, `evaluate metrics name is duplicated: ${name}`);
1270
+ observations.push({ name, step: index + 1, value: await collectDomMetrics(page, locator) });
1271
+ break;
1272
+ }
1273
+ default:
1274
+ throw flowError(profileId, index, "evaluate requires operation scrollTo, scrollBy, or metrics; executable JavaScript is not allowed");
1275
+ }
1276
+ }
1277
+
1278
+ async function collectDomMetrics(page, locator) {
1279
+ if (locator) {
1280
+ return locator.evaluate((element) => {
1281
+ const rect = element.getBoundingClientRect();
1282
+ return {
1283
+ scrollLeft: element.scrollLeft,
1284
+ scrollTop: element.scrollTop,
1285
+ scrollWidth: element.scrollWidth,
1286
+ scrollHeight: element.scrollHeight,
1287
+ clientWidth: element.clientWidth,
1288
+ clientHeight: element.clientHeight,
1289
+ x: rect.x,
1290
+ y: rect.y,
1291
+ width: rect.width,
1292
+ height: rect.height,
1293
+ };
1294
+ });
1295
+ }
1296
+ return page.evaluate(() => {
1297
+ const root = document.scrollingElement ?? document.documentElement;
1298
+ return {
1299
+ scrollX: window.scrollX,
1300
+ scrollY: window.scrollY,
1301
+ scrollWidth: root.scrollWidth,
1302
+ scrollHeight: root.scrollHeight,
1303
+ clientWidth: root.clientWidth,
1304
+ clientHeight: root.clientHeight,
1305
+ viewportWidth: window.innerWidth,
1306
+ viewportHeight: window.innerHeight,
1307
+ };
1308
+ });
1309
+ }
1310
+
1311
+ function scrollPosition(value, profileId, index, label) {
1312
+ if (value === undefined || value === "max") return value;
1313
+ return finiteNumber(value, profileId, index, label);
1314
+ }
1315
+
1316
+ function optionalFiniteNumber(value, fallback, profileId, index, label) {
1317
+ return value === undefined ? fallback : finiteNumber(value, profileId, index, label);
1318
+ }
1319
+
1320
+ function finiteNumber(value, profileId, index, label) {
1321
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > MAX_SCROLL_DISTANCE) {
1322
+ throw flowError(profileId, index, `${label} must be a finite number between -${MAX_SCROLL_DISTANCE} and ${MAX_SCROLL_DISTANCE}`);
1323
+ }
1324
+ return value;
1325
+ }
1326
+
1327
+ function numericComparisonMatcher(step, profileId, index) {
1328
+ const comparisons = ["equals", "greaterThan", "greaterThanOrEqual", "lessThan", "lessThanOrEqual"]
1329
+ .filter((key) => typeof step[key] === "number" && Number.isFinite(step[key]));
1330
+ if (comparisons.length !== 1) throw flowError(profileId, index, "assertDOMMetric requires exactly one numeric comparator");
1331
+ const key = comparisons[0];
1332
+ const expected = step[key];
1333
+ switch (key) {
1334
+ case "equals": return (actual) => actual === expected;
1335
+ case "greaterThan": return (actual) => actual > expected;
1336
+ case "greaterThanOrEqual": return (actual) => actual >= expected;
1337
+ case "lessThan": return (actual) => actual < expected;
1338
+ case "lessThanOrEqual": return (actual) => actual <= expected;
443
1339
  }
444
1340
  }
445
1341
 
@@ -460,15 +1356,25 @@ function requireLocator(locator, profileId, index) {
460
1356
  return locator;
461
1357
  }
462
1358
 
463
- function assertExpectedString(actual, step, profileId, index, label) {
1359
+ function expectedStringMatcher(step, profileId, index, label) {
464
1360
  const hasEquals = typeof step.equals === "string";
465
1361
  const hasIncludes = typeof step.includes === "string";
466
1362
  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`);
1363
+ return hasEquals ? (actual) => actual === step.equals : (actual) => actual.includes(step.includes);
468
1364
  }
469
1365
 
470
- function assertCondition(condition, profileId, index, reason) {
471
- if (!condition) throw flowError(profileId, index, reason);
1366
+ async function retryAssertion({ page, timeout, profileId, index, reason, check }) {
1367
+ const deadline = Date.now() + timeout;
1368
+ while (true) {
1369
+ try {
1370
+ if (await check()) return;
1371
+ } catch {
1372
+ // Dynamic UI may temporarily detach or replace the target; retry until the assertion deadline.
1373
+ }
1374
+ const remaining = deadline - Date.now();
1375
+ if (remaining <= 0) throw flowError(profileId, index, reason);
1376
+ await page.waitForTimeout(Math.min(ASSERTION_POLL_INTERVAL_MS, remaining));
1377
+ }
472
1378
  }
473
1379
 
474
1380
  function flowError(profileId, index, reason) {
@@ -527,7 +1433,9 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
527
1433
  }
528
1434
  await page.locator(auth.submitSelector).click();
529
1435
  if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
530
- if (isObject(auth.success) && typeof auth.success.selector === "string") await page.locator(auth.success.selector).waitFor({ timeout });
1436
+ if (isObject(auth.success) && typeof auth.success.selector === "string") {
1437
+ await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
1438
+ }
531
1439
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
532
1440
  throw authError(profileId, "form success.url or success.selector is required");
533
1441
  }
@@ -664,12 +1572,215 @@ function parseArgs(values) {
664
1572
  const value = values[index + 1];
665
1573
  if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
666
1574
  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);
1575
+ if (!["profile", "flow", "baseUrl", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
668
1576
  result[key] = value;
669
1577
  }
670
1578
  return result;
671
1579
  }
672
1580
 
1581
+ function parseAuthScaffoldArgs(values) {
1582
+ const result = {};
1583
+ for (let index = 0; index < values.length; index += 2) {
1584
+ const flag = values[index];
1585
+ const value = values[index + 1];
1586
+ if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
1587
+ const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1588
+ if (!["profile", "loginUrl", "baseUrl", "description", "successUrl", "successSelector", "successState", "runnerTimeoutMs"].includes(key)) {
1589
+ throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
1590
+ }
1591
+ if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
1592
+ result[key] = value;
1593
+ }
1594
+ if (result.description !== undefined) {
1595
+ result.description = boundedScaffoldString(result.description, "--description");
1596
+ if (result.description.length > 200) throw new QaStatusError("QA_RUN_FAILED", "--description must be no longer than 200 characters", 1);
1597
+ }
1598
+ return result;
1599
+ }
1600
+
1601
+ function parseRunnerTimeout(value, profileId) {
1602
+ if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
1603
+ const parsed = Number(value);
1604
+ if (!Number.isFinite(parsed) || parsed < 100 || parsed > MAX_RUNNER_TIMEOUT_MS) {
1605
+ throw new QaStatusError(
1606
+ "QA_RUN_FAILED",
1607
+ `--runner-timeout-ms must be between 100 and ${MAX_RUNNER_TIMEOUT_MS}`,
1608
+ 1,
1609
+ profileId,
1610
+ );
1611
+ }
1612
+ return Math.round(parsed);
1613
+ }
1614
+
1615
+ function createRunnerProgress(workspaceDir) {
1616
+ const progressPath = path.join(workspaceDir, "progress.jsonl");
1617
+ let lastStage;
1618
+ const write = (stage, details = {}) => {
1619
+ lastStage = stage;
1620
+ if (fs.existsSync(progressPath) && fs.statSync(progressPath).size >= PROGRESS_LOG_MAX_BYTES) {
1621
+ fs.writeFileSync(progressPath, "", { mode: 0o600 });
1622
+ }
1623
+ fs.appendFileSync(progressPath, `${JSON.stringify({ at: new Date().toISOString(), stage, ...details })}\n`, { mode: 0o600 });
1624
+ };
1625
+ write.lastStage = () => lastStage;
1626
+ return write;
1627
+ }
1628
+
1629
+ function terminateRunnerDescendants() {
1630
+ if (process.platform === "win32") {
1631
+ // Detached children are not covered by a POSIX process-group kill. Walk
1632
+ // the Windows process tree explicitly so a timed-out Playwright/browser
1633
+ // child cannot keep the private QA workspace locked after the runner exits.
1634
+ const script = `
1635
+ $root = ${process.pid}
1636
+ $processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId)
1637
+ $pending = [System.Collections.Generic.Queue[int]]::new()
1638
+ $pending.Enqueue($root)
1639
+ $descendants = [System.Collections.Generic.HashSet[int]]::new()
1640
+ while ($pending.Count -gt 0) {
1641
+ $parent = $pending.Dequeue()
1642
+ foreach ($candidate in $processes) {
1643
+ $candidatePid = [int]$candidate.ProcessId
1644
+ if ([int]$candidate.ParentProcessId -eq $parent -and $candidatePid -ne $root -and $descendants.Add($candidatePid)) {
1645
+ $pending.Enqueue($candidatePid)
1646
+ }
1647
+ }
1648
+ }
1649
+ foreach ($candidatePid in $descendants) {
1650
+ Stop-Process -Id $candidatePid -Force -ErrorAction SilentlyContinue
1651
+ }
1652
+ `;
1653
+ spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
1654
+ stdio: "ignore",
1655
+ timeout: 2_000,
1656
+ windowsHide: true,
1657
+ });
1658
+ return;
1659
+ }
1660
+ const snapshot = spawnSync("ps", ["-axo", "pid=,ppid=,pgid="], {
1661
+ encoding: "utf8",
1662
+ timeout: 1000,
1663
+ maxBuffer: 2 * 1024 * 1024,
1664
+ });
1665
+ if (snapshot.status !== 0 || !snapshot.stdout) return;
1666
+ const processes = snapshot.stdout
1667
+ .split("\n")
1668
+ .map((line) => line.trim().split(/\s+/).map(Number))
1669
+ .filter(([pid, parentPid, groupId]) => Number.isInteger(pid) && Number.isInteger(parentPid) && Number.isInteger(groupId))
1670
+ .map(([pid, parentPid, groupId]) => ({ pid, parentPid, groupId }));
1671
+ const descendants = [];
1672
+ const pendingParents = [process.pid];
1673
+ for (let index = 0; index < pendingParents.length; index += 1) {
1674
+ const parentPid = pendingParents[index];
1675
+ for (const candidate of processes) {
1676
+ if (candidate.parentPid !== parentPid || descendants.some(({ pid }) => pid === candidate.pid)) continue;
1677
+ descendants.push(candidate);
1678
+ pendingParents.push(candidate.pid);
1679
+ }
1680
+ }
1681
+ const ownGroup = processes.find(({ pid }) => pid === process.pid)?.groupId;
1682
+ for (const { pid, groupId } of descendants) {
1683
+ if (pid !== groupId || groupId === ownGroup) continue;
1684
+ try {
1685
+ process.kill(-groupId, "SIGKILL");
1686
+ } catch { /* already gone */ }
1687
+ }
1688
+ for (const { pid } of descendants) {
1689
+ try {
1690
+ process.kill(pid, "SIGKILL");
1691
+ } catch { /* already gone */ }
1692
+ }
1693
+ }
1694
+
1695
+ function sanitizeTraceArchiveInWorker(tracePath, secrets, deadline) {
1696
+ return new Promise((resolve, reject) => {
1697
+ const worker = new Worker(new URL(import.meta.url), {
1698
+ workerData: { operation: "sanitize_trace", tracePath, secrets },
1699
+ resourceLimits: { maxOldGenerationSizeMb: 256 },
1700
+ });
1701
+ let settled = false;
1702
+ const finish = (error) => {
1703
+ if (settled) return;
1704
+ settled = true;
1705
+ clearTimeout(timer);
1706
+ void worker.terminate();
1707
+ if (error) reject(error);
1708
+ else resolve();
1709
+ };
1710
+ const timer = setTimeout(
1711
+ () => finish(new RunnerStageTimeoutError("trace_sanitize")),
1712
+ Math.max(1, deadline - Date.now()),
1713
+ );
1714
+ worker.once("message", (message) => {
1715
+ if (message?.success === true) finish();
1716
+ else finish(new Error(typeof message?.reason === "string" ? message.reason : "trace sanitization failed"));
1717
+ });
1718
+ worker.once("error", finish);
1719
+ worker.once("exit", (code) => {
1720
+ if (code !== 0) finish(new Error(`trace sanitizer exited with code ${code}`));
1721
+ });
1722
+ });
1723
+ }
1724
+
1725
+ async function runStage(progress, stage, deadline, operation, profileId) {
1726
+ progress(`${stage}_started`);
1727
+ try {
1728
+ const result = await withDeadline(operation(), deadline, stage);
1729
+ progress(`${stage}_finished`);
1730
+ return result;
1731
+ } catch (error) {
1732
+ if (error instanceof RunnerStageTimeoutError) {
1733
+ progress(`${stage}_timed_out`);
1734
+ throw new QaStatusError(
1735
+ "QA_RUN_FAILED",
1736
+ `browser QA stage ${stage} timed out`,
1737
+ EXIT_RUNNER_TIMEOUT,
1738
+ profileId,
1739
+ { timedOut: true, lastStage: stage },
1740
+ );
1741
+ }
1742
+ progress(`${stage}_failed`, { errorType: error?.constructor?.name ?? typeof error });
1743
+ throw error;
1744
+ }
1745
+ }
1746
+
1747
+ async function runCleanupStage(progress, stage, operation, deadline = Date.now() + CLEANUP_TIMEOUT_MS) {
1748
+ progress(`${stage}_started`);
1749
+ try {
1750
+ const result = await withDeadline(operation(), deadline, stage);
1751
+ progress(`${stage}_finished`);
1752
+ return result;
1753
+ } catch (error) {
1754
+ progress(error instanceof RunnerStageTimeoutError ? `${stage}_timed_out` : `${stage}_failed`, {
1755
+ errorType: error?.constructor?.name ?? typeof error,
1756
+ });
1757
+ throw error;
1758
+ }
1759
+ }
1760
+
1761
+ class RunnerStageTimeoutError extends Error {
1762
+ constructor(stage) {
1763
+ super(`browser QA stage ${stage} timed out`);
1764
+ this.name = "RunnerStageTimeoutError";
1765
+ }
1766
+ }
1767
+
1768
+ async function withDeadline(promise, deadline, stage) {
1769
+ const remaining = deadline - Date.now();
1770
+ if (remaining <= 0) throw new RunnerStageTimeoutError(stage);
1771
+ let timer;
1772
+ try {
1773
+ return await Promise.race([
1774
+ promise,
1775
+ new Promise((_, reject) => {
1776
+ timer = setTimeout(() => reject(new RunnerStageTimeoutError(stage)), remaining);
1777
+ }),
1778
+ ]);
1779
+ } finally {
1780
+ if (timer) clearTimeout(timer);
1781
+ }
1782
+ }
1783
+
673
1784
  function collectSecrets(value, output = []) {
674
1785
  if (!isObject(value)) return output;
675
1786
  if (typeof value.token === "string" && value.token.length > 0) output.push(value.token);
@@ -721,13 +1832,21 @@ function expandSecretForms(values) {
721
1832
  return [...forms].filter(Boolean).sort((left, right) => right.length - left.length);
722
1833
  }
723
1834
 
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;
1835
+ async function assertBrowserDoesNotExposeSecrets(context, fallbackPage, secrets, profileId) {
1836
+ if (secrets.length === 0) return;
1837
+ const pages = typeof context?.pages === "function" ? context.pages() : [fallbackPage];
1838
+ for (const page of pages) {
1839
+ if (!page || page.isClosed()) continue;
1840
+ const frames = typeof page.frames === "function" ? page.frames() : [page];
1841
+ for (const frame of frames) {
1842
+ let haystacks;
1843
+ try { haystacks = [frame.url(), await frame.content()]; } catch { continue; }
1844
+ if (!secrets.some((secret) => secret && haystacks.some((value) => value.includes(secret)))) continue;
1845
+ const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in rendered page content; visual evidence was discarded", 1, profileId);
1846
+ error.suppressEvidence = true;
1847
+ throw error;
1848
+ }
1849
+ }
731
1850
  }
732
1851
 
733
1852
  function sanitizeTraceArchive(tracePath, secrets) {
@@ -889,6 +2008,67 @@ function createAuthTemplate(cwd) {
889
2008
  return true;
890
2009
  }
891
2010
 
2011
+ function writeAuthScaffold(cwd, profileId, profile) {
2012
+ const initialTarget = assertAuthScaffoldWritable(cwd);
2013
+ const content = `// Generated by the trusted browser QA runner from the public login form.\n// Replace only __PI_QA_SECRET_n__ values yourself; agents must not read or edit this file.\n${JSON.stringify({ profiles: { [profileId]: profile } }, null, 2)}\n`;
2014
+ const temporaryFile = `${initialTarget.file}.scaffold-${randomUUID()}.tmp`;
2015
+ try {
2016
+ fs.writeFileSync(temporaryFile, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
2017
+ const descriptor = fs.openSync(temporaryFile, "r+");
2018
+ try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
2019
+
2020
+ const currentTarget = assertAuthScaffoldWritable(cwd);
2021
+ if (currentTarget.replacedEmptyTemplate !== initialTarget.replacedEmptyTemplate) {
2022
+ throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
2023
+ }
2024
+ if (currentTarget.replacedEmptyTemplate) {
2025
+ fs.renameSync(temporaryFile, currentTarget.file);
2026
+ } else {
2027
+ try {
2028
+ fs.linkSync(temporaryFile, currentTarget.file);
2029
+ } catch (error) {
2030
+ if (isAlreadyExistsError(error)) throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
2031
+ throw error;
2032
+ }
2033
+ fs.unlinkSync(temporaryFile);
2034
+ }
2035
+ return currentTarget.replacedEmptyTemplate;
2036
+ } finally {
2037
+ if (fs.existsSync(temporaryFile)) fs.unlinkSync(temporaryFile);
2038
+ }
2039
+ }
2040
+
2041
+ function assertAuthScaffoldWritable(cwd) {
2042
+ const root = fs.realpathSync(cwd);
2043
+ const directory = path.join(root, path.dirname(CONFIG_RELATIVE));
2044
+ const file = path.join(root, CONFIG_RELATIVE);
2045
+ assertInside(root, directory, "auth config directory");
2046
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
2047
+ const directoryStat = fs.lstatSync(directory);
2048
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
2049
+ throw new QaStatusError("QA_RUN_FAILED", "auth config directory must be a real project-local directory", 1);
2050
+ }
2051
+
2052
+ let replacedEmptyTemplate = false;
2053
+ if (fs.existsSync(file)) {
2054
+ const existing = resolveExistingPrivateFile(root, CONFIG_RELATIVE, "auth config");
2055
+ const existingContent = fs.readFileSync(existing, "utf8");
2056
+ const errors = [];
2057
+ const value = parseJsonc(existingContent, errors, { allowTrailingComma: true });
2058
+ if (errors.length > 0 || !isObject(value) || !isObject(value.profiles)) {
2059
+ throw new QaStatusError("QA_RUN_FAILED", "existing auth config is invalid; scaffold creation will not overwrite it", 1);
2060
+ }
2061
+ if (Object.keys(value.profiles).length > 0) {
2062
+ throw new QaStatusError("QA_RUN_FAILED", "existing auth config has profiles; scaffold creation will not overwrite it", 1);
2063
+ }
2064
+ if (existingContent !== AUTH_TEMPLATE) {
2065
+ throw new QaStatusError("QA_RUN_FAILED", "existing empty auth config was not generated by the trusted runner; scaffold creation will not overwrite it", 1);
2066
+ }
2067
+ replacedEmptyTemplate = true;
2068
+ }
2069
+ return { file, replacedEmptyTemplate };
2070
+ }
2071
+
892
2072
  function isAlreadyExistsError(error) {
893
2073
  return error !== null && typeof error === "object" && error.code === "EEXIST";
894
2074
  }
@@ -936,25 +2116,26 @@ function relativePath(cwd, value) {
936
2116
 
937
2117
  function existingEvidence(evidenceDir) {
938
2118
  return fs.readdirSync(evidenceDir)
939
- .filter((name) => /^(?:[A-Za-z0-9._-]+\.png|video\.webm|trace\.zip)$/.test(name))
2119
+ .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
2120
  .sort();
941
2121
  }
942
2122
 
943
2123
  function artifactManifest(evidenceDir, evidence) {
944
- const artifacts = { screenshots: [], videos: [], traces: [] };
2124
+ const artifacts = { screenshots: [], videos: [], traces: [], downloads: [] };
945
2125
  for (const name of evidence) {
946
2126
  const absolutePath = path.resolve(evidenceDir, name);
947
2127
  const artifact = { path: absolutePath, uri: pathToFileURL(absolutePath).href };
948
2128
  if (name.endsWith(".png")) artifacts.screenshots.push(artifact);
949
2129
  else if (name.endsWith(".webm")) artifacts.videos.push(artifact);
950
2130
  else if (name.endsWith(".zip")) artifacts.traces.push(artifact);
2131
+ else if (name.endsWith(".bin")) artifacts.downloads.push(artifact);
951
2132
  }
952
2133
  return artifacts;
953
2134
  }
954
2135
 
955
2136
  function removeVisualEvidence(evidenceDir) {
956
2137
  for (const name of fs.readdirSync(evidenceDir)) {
957
- if (/\.(?:png|webm|zip)$/.test(name)) fs.rmSync(path.join(evidenceDir, name), { force: true });
2138
+ if (/\.(?:png|webm|zip|bin)$/.test(name)) fs.rmSync(path.join(evidenceDir, name), { force: true });
958
2139
  }
959
2140
  }
960
2141