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.
- package/README.md +5 -1
- package/dist/app/app.d.ts +1 -0
- package/dist/app/app.js +12 -0
- package/dist/app/constants.d.ts +1 -1
- package/dist/app/constants.js +1 -1
- package/dist/app/icons.d.ts +1 -0
- package/dist/app/icons.js +2 -0
- package/dist/app/input/input-controller.d.ts +2 -0
- package/dist/app/input/input-controller.js +8 -5
- package/dist/app/rendering/render-controller.js +2 -0
- package/dist/app/rendering/status-line-renderer.d.ts +4 -1
- package/dist/app/rendering/status-line-renderer.js +12 -0
- package/dist/app/runtime.d.ts +1 -8
- package/dist/app/runtime.js +0 -39
- package/dist/app/screen/mouse-controller.d.ts +4 -1
- package/dist/app/screen/mouse-controller.js +9 -0
- package/dist/app/session/agent-pause-controller.d.ts +30 -0
- package/dist/app/session/agent-pause-controller.js +178 -0
- package/dist/app/session/session-event-controller.d.ts +1 -0
- package/dist/app/session/session-event-controller.js +22 -15
- package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
- package/dist/app/session/session-lifecycle-controller.js +1 -0
- package/dist/app/types.d.ts +10 -0
- package/dist/config.js +1 -1
- package/dist/default-pix-config.js +2 -2
- package/external/pi-tools-suite/README.md +1 -1
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +34 -2
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +24 -18
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +14 -3
- package/external/pi-tools-suite/src/async-subagents/core/process.ts +19 -0
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +97 -17
- package/external/pi-tools-suite/src/async-subagents/core/stop.ts +4 -2
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +108 -13
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +80 -0
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-flow.example.jsonc +54 -1
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +952 -76
- package/external/pi-tools-suite/src/coding-discipline/index.ts +2 -94
- package/external/pi-tools-suite/src/dcp/index.ts +21 -0
- package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +92 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +21 -16
- package/external/pi-tools-suite/src/todo/index.ts +86 -46
- package/external/pi-tools-suite/src/todo/todo.ts +1 -1
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
125
|
-
viewport
|
|
126
|
-
});
|
|
127
|
-
await installOriginGuard(context, allowedOrigins, profile.auth);
|
|
128
|
-
await applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId);
|
|
129
|
-
page = await context.newPage();
|
|
130
|
-
video
|
|
131
|
-
await applyFormAuth(page, profile.auth, allowedOrigins, profileId);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await
|
|
135
|
-
await
|
|
136
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
553
|
+
await scope.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
|
|
381
554
|
break;
|
|
382
555
|
}
|
|
383
|
-
case "reload":
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
414
|
-
|
|
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
|
|
419
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
736
|
+
}
|
|
427
737
|
case "authRejectedIf": {
|
|
428
|
-
const urlRejected = typeof step.urlIncludes === "string" &&
|
|
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
|
|
437
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1141
|
+
return hasEquals ? (actual) => actual === step.equals : (actual) => actual.includes(step.includes);
|
|
468
1142
|
}
|
|
469
1143
|
|
|
470
|
-
function
|
|
471
|
-
|
|
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
|
|
725
|
-
if (
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
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
|
|