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.
- package/README.md +5 -1
- package/dist/app/app.d.ts +1 -0
- package/dist/app/app.js +12 -0
- 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/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/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 +150 -19
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +78 -0
- 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 +1260 -79
- 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 +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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
775
|
+
await scope.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
|
|
381
776
|
break;
|
|
382
777
|
}
|
|
383
|
-
case "reload":
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
414
|
-
|
|
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
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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
|
|
437
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1363
|
+
return hasEquals ? (actual) => actual === step.equals : (actual) => actual.includes(step.includes);
|
|
468
1364
|
}
|
|
469
1365
|
|
|
470
|
-
function
|
|
471
|
-
|
|
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")
|
|
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
|
|
725
|
-
if (
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
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
|
|