@tea-agent/loop-agent 0.27.1 → 0.28.0
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/CHANGELOG.md +24 -0
- package/dist/application/task-lifecycle/observe.js +5 -0
- package/dist/application/task-lifecycle/plan-transitions.js +7 -2
- package/dist/cli/program.js +1 -1
- package/dist/commands/client-recovery.js +439 -20
- package/dist/commands/init.js +42 -6
- package/dist/executors/dag-pi-executor.js +143 -38
- package/dist/executors/pi-playwright-cli-tool.js +955 -0
- package/dist/executors/pi-sdk-executor.js +56 -0
- package/dist/executors/playwright-cli-launcher.js +63 -0
- package/dist/executors/shell-executor.js +128 -0
- package/dist/shared/playwright-cli-command-policy.js +41 -0
- package/dist/worker/observability/read-model.js +66 -8
- package/dist/worker/observe/static/dag-model.js +85 -13
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
- package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
- package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
- package/dist/workflows/dag/init-hybrid.js +116 -30
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/node-execution.js +11 -5
- package/dist/workflows/dag/output-protocol.js +25 -83
- package/dist/workflows/dag/report.js +9 -2
- package/dist/workflows/dag/rerun-run.js +62 -3
- package/dist/workflows/dag/run-store.js +6 -1
- package/dist/workflows/dag/runner.js +15 -3
- package/dist/workflows/dag/types.js +27 -0
- package/dist/workflows/dag/validate.js +121 -1
- package/docs/architecture/runtime-boundaries.md +13 -11
- package/docs/init-surface.manifest.json +6 -2
- package/docs/templates/README.md +9 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
- package/docs/templates/frontend-test-dag.json +55 -15
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +18 -6
- package/skills/playwright-cli/SKILL.md +69 -402
- package/skills/playwright-cli/references/tracing.md +3 -137
- package/skills/playwright-cli/references/video-recording.md +3 -141
- package/skills/playwright-cli-case-generator/SKILL.md +53 -46
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { appendFile, lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { processTreeSpawnOptions, terminateProcessTree } from "./process-tree.js";
|
|
6
|
+
import { resolvePlaywrightCliLauncher } from "./playwright-cli-launcher.js";
|
|
7
|
+
import { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, } from "../shared/playwright-cli-command-policy.js";
|
|
8
|
+
export { PLAYWRIGHT_CLI_ALLOWED_COMMANDS, isPlaywrightCliCommand, };
|
|
9
|
+
/** Logical capability name only; execution always uses the verified JS launcher. */
|
|
10
|
+
export const PLAYWRIGHT_CLI_EXECUTABLE = "playwright-cli";
|
|
11
|
+
export const PLAYWRIGHT_CLI_INTERACTION_COMMANDS = new Set([
|
|
12
|
+
"goto",
|
|
13
|
+
"snapshot",
|
|
14
|
+
"find",
|
|
15
|
+
"click",
|
|
16
|
+
"dblclick",
|
|
17
|
+
"fill",
|
|
18
|
+
"type",
|
|
19
|
+
"press",
|
|
20
|
+
"keydown",
|
|
21
|
+
"keyup",
|
|
22
|
+
"hover",
|
|
23
|
+
"select",
|
|
24
|
+
"check",
|
|
25
|
+
"uncheck",
|
|
26
|
+
"drag",
|
|
27
|
+
"drop",
|
|
28
|
+
"upload",
|
|
29
|
+
"go-back",
|
|
30
|
+
"go-forward",
|
|
31
|
+
"reload",
|
|
32
|
+
"dialog-accept",
|
|
33
|
+
"dialog-dismiss",
|
|
34
|
+
"resize",
|
|
35
|
+
"screenshot",
|
|
36
|
+
"pdf",
|
|
37
|
+
"console",
|
|
38
|
+
"requests",
|
|
39
|
+
"request",
|
|
40
|
+
]);
|
|
41
|
+
const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
42
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
43
|
+
const CONTROL_META = /(?:&&|\|\||[;|`$<>\n\r])/;
|
|
44
|
+
const SESSION_FLAG = /^(?:-s(?:=|$)|--session(?:=|$))/;
|
|
45
|
+
const OUTPUT_PATH_FLAGS = new Set([
|
|
46
|
+
"--filename",
|
|
47
|
+
"--path",
|
|
48
|
+
"--output",
|
|
49
|
+
"-o",
|
|
50
|
+
"--file",
|
|
51
|
+
]);
|
|
52
|
+
const SENSITIVE_COMMANDS = new Set(["fill", "type", "upload", "drop"]);
|
|
53
|
+
function normalizePosix(value) {
|
|
54
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
55
|
+
}
|
|
56
|
+
function isSafeRelativePosix(value) {
|
|
57
|
+
const normalized = normalizePosix(value);
|
|
58
|
+
if (!normalized || path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return normalized.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
62
|
+
}
|
|
63
|
+
export function redactPlaywrightCliArgs(command, args) {
|
|
64
|
+
return args.map((arg) => {
|
|
65
|
+
// Receipts are durable run evidence: do not retain selector/value ambiguity,
|
|
66
|
+
// sensitive source text, upload paths, or flag values for sensitive operations.
|
|
67
|
+
// Denied legacy page-script requests still receive the durable redaction
|
|
68
|
+
// treatment so a rejected request cannot leak source or credentials.
|
|
69
|
+
if (SENSITIVE_COMMANDS.has(command) || command === "eval")
|
|
70
|
+
return "<redacted>";
|
|
71
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(arg);
|
|
74
|
+
url.username = "";
|
|
75
|
+
url.password = "";
|
|
76
|
+
if (url.search)
|
|
77
|
+
url.search = "";
|
|
78
|
+
if (url.hash)
|
|
79
|
+
url.hash = "";
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return "<redacted-url>";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (/(?:password|token|secret|authorization)=/i.test(arg)) {
|
|
87
|
+
return "<redacted-kv>";
|
|
88
|
+
}
|
|
89
|
+
return arg;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export function resolvePlaywrightCliReceiptPath(runDir, nodeId) {
|
|
93
|
+
return path.join(runDir, nodeId, "playwright-cli-receipt.jsonl");
|
|
94
|
+
}
|
|
95
|
+
export async function readPlaywrightCliReceipts(runDir, nodeId) {
|
|
96
|
+
const receiptPath = resolvePlaywrightCliReceiptPath(runDir, nodeId);
|
|
97
|
+
try {
|
|
98
|
+
const raw = await readFile(receiptPath, "utf8");
|
|
99
|
+
return raw
|
|
100
|
+
.split(/\r?\n/)
|
|
101
|
+
.map((line) => line.trim())
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((line) => JSON.parse(line));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function summarizePlaywrightCliReceipts(receipts) {
|
|
110
|
+
let hasSuccessfulOpen = false;
|
|
111
|
+
let hasInteractionOrAssertion = false;
|
|
112
|
+
let hasMeaningfulAssertion = false;
|
|
113
|
+
let hasCleanup = false;
|
|
114
|
+
let hasPostExecutionCleanup = false;
|
|
115
|
+
let sawOpen = false;
|
|
116
|
+
let sawMeaningfulAssertionAfterOpen = false;
|
|
117
|
+
let hasOrderedPassedReceiptChain = false;
|
|
118
|
+
let hasContiguousControllerSequence = true;
|
|
119
|
+
let previousSequence = 0;
|
|
120
|
+
const caseIds = new Set();
|
|
121
|
+
for (const receipt of receipts) {
|
|
122
|
+
caseIds.add(receipt.caseId);
|
|
123
|
+
const succeeded = receipt.exitCode === 0 && !receipt.timedOut;
|
|
124
|
+
const ordered = receipt.sequence === previousSequence + 1;
|
|
125
|
+
if (!ordered)
|
|
126
|
+
hasContiguousControllerSequence = false;
|
|
127
|
+
previousSequence = receipt.sequence;
|
|
128
|
+
if (receipt.command === "open" && succeeded) {
|
|
129
|
+
hasSuccessfulOpen = true;
|
|
130
|
+
if (ordered)
|
|
131
|
+
sawOpen = true;
|
|
132
|
+
}
|
|
133
|
+
if (PLAYWRIGHT_CLI_INTERACTION_COMMANDS.has(receipt.command) && succeeded) {
|
|
134
|
+
hasInteractionOrAssertion = true;
|
|
135
|
+
}
|
|
136
|
+
// A successful find is the only model-visible semantic assertion in v1.
|
|
137
|
+
if (receipt.command === "find" && succeeded) {
|
|
138
|
+
hasMeaningfulAssertion = true;
|
|
139
|
+
if (ordered && sawOpen)
|
|
140
|
+
sawMeaningfulAssertionAfterOpen = true;
|
|
141
|
+
}
|
|
142
|
+
if (receipt.controllerCleanup && succeeded) {
|
|
143
|
+
hasCleanup = true;
|
|
144
|
+
if (receipt.cleanupPhase === "post-execution") {
|
|
145
|
+
hasPostExecutionCleanup = true;
|
|
146
|
+
if (ordered && sawMeaningfulAssertionAfterOpen) {
|
|
147
|
+
hasOrderedPassedReceiptChain = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
hasSuccessfulOpen,
|
|
154
|
+
hasInteractionOrAssertion,
|
|
155
|
+
hasMeaningfulAssertion,
|
|
156
|
+
hasCleanup,
|
|
157
|
+
hasPostExecutionCleanup,
|
|
158
|
+
hasOrderedPassedReceiptChain: hasOrderedPassedReceiptChain && hasContiguousControllerSequence,
|
|
159
|
+
caseIds,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function assertNoControlMeta(arg) {
|
|
163
|
+
if (CONTROL_META.test(arg)) {
|
|
164
|
+
throw new PlaywrightCliPolicyError("shell-control-meta", `argument contains shell control metacharacters: ${arg}`);
|
|
165
|
+
}
|
|
166
|
+
if (arg.includes("\0")) {
|
|
167
|
+
throw new PlaywrightCliPolicyError("nul-byte", "argument contains NUL byte");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function assertNoSessionFlag(arg) {
|
|
171
|
+
if (SESSION_FLAG.test(arg) || arg === "--session" || arg.startsWith("--session=")) {
|
|
172
|
+
throw new PlaywrightCliPolicyError("named-session-forbidden", "named browser sessions are forbidden; use the default session only");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function isProductionHost(hostname) {
|
|
176
|
+
const host = hostname.toLowerCase();
|
|
177
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1")
|
|
178
|
+
return false;
|
|
179
|
+
if (host.endsWith(".local") || host.endsWith(".test") || host.endsWith(".localhost")) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return /(?:^|\.)prod(?:uction)?(?:\.|$)/i.test(host) || /(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(host);
|
|
183
|
+
}
|
|
184
|
+
function validateOpenArgs(args, baseUrl) {
|
|
185
|
+
const flags = new Set();
|
|
186
|
+
let url;
|
|
187
|
+
const normalized = [];
|
|
188
|
+
for (const arg of args) {
|
|
189
|
+
assertNoControlMeta(arg);
|
|
190
|
+
assertNoSessionFlag(arg);
|
|
191
|
+
if (arg === "--browser=chrome" || arg === "--headed") {
|
|
192
|
+
flags.add(arg);
|
|
193
|
+
normalized.push(arg);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (arg.startsWith("--browser=") || arg === "--browser" || arg === "--headless") {
|
|
197
|
+
throw new PlaywrightCliPolicyError("open-browser-flags", "open must use --browser=chrome --headed only");
|
|
198
|
+
}
|
|
199
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(arg) || arg.startsWith("http")) {
|
|
200
|
+
url = arg;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
throw new PlaywrightCliPolicyError("open-args", `unsupported open argument: ${arg}`);
|
|
204
|
+
}
|
|
205
|
+
if (!flags.has("--browser=chrome") || !flags.has("--headed")) {
|
|
206
|
+
// controller injects required flags when missing from model args
|
|
207
|
+
if (!flags.has("--browser=chrome"))
|
|
208
|
+
normalized.unshift("--browser=chrome");
|
|
209
|
+
if (!flags.has("--headed"))
|
|
210
|
+
normalized.push("--headed");
|
|
211
|
+
}
|
|
212
|
+
if (!url) {
|
|
213
|
+
throw new PlaywrightCliPolicyError("open-url-required", "open requires an absolute http(s) URL");
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = new URL(url);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw new PlaywrightCliPolicyError("open-url-invalid", `invalid open URL: ${url}`);
|
|
221
|
+
}
|
|
222
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
223
|
+
throw new PlaywrightCliPolicyError("open-url-scheme", `open URL scheme must be http(s); got ${parsed.protocol}`);
|
|
224
|
+
}
|
|
225
|
+
if (parsed.username || parsed.password) {
|
|
226
|
+
throw new PlaywrightCliPolicyError("open-url-credentials", "open URL must not include credentials");
|
|
227
|
+
}
|
|
228
|
+
if (isProductionHost(parsed.hostname)) {
|
|
229
|
+
throw new PlaywrightCliPolicyError("open-url-production", `production host forbidden: ${parsed.hostname}`);
|
|
230
|
+
}
|
|
231
|
+
let frozen;
|
|
232
|
+
try {
|
|
233
|
+
frozen = new URL(baseUrl ?? "");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw new PlaywrightCliPolicyError("base-url-invalid", "controller-owned baseUrl is invalid or missing");
|
|
237
|
+
}
|
|
238
|
+
if (frozen.protocol !== "http:" && frozen.protocol !== "https:") {
|
|
239
|
+
throw new PlaywrightCliPolicyError("base-url-invalid", "controller-owned baseUrl must be http(s)");
|
|
240
|
+
}
|
|
241
|
+
if (parsed.origin !== frozen.origin) {
|
|
242
|
+
throw new PlaywrightCliPolicyError("open-url-origin", `open URL origin ${parsed.origin} must match frozen baseUrl origin ${frozen.origin}`);
|
|
243
|
+
}
|
|
244
|
+
normalized.push(parsed.toString());
|
|
245
|
+
return normalized;
|
|
246
|
+
}
|
|
247
|
+
function resolveEvidenceRelative(rawPath, ctx) {
|
|
248
|
+
const evidenceDir = normalizePosix(ctx.evidenceDir).replace(/\/$/, "");
|
|
249
|
+
const evidencePrefix = `testcase/frontend/evidence/${ctx.caseId}`;
|
|
250
|
+
if (!(evidenceDir === evidencePrefix || evidenceDir.startsWith(`${evidencePrefix}/`))) {
|
|
251
|
+
throw new PlaywrightCliPolicyError("evidence-dir-invalid", `evidenceDir must be under ${evidencePrefix}`);
|
|
252
|
+
}
|
|
253
|
+
const candidate = normalizePosix(rawPath);
|
|
254
|
+
if (!candidate || candidate.includes("\0")) {
|
|
255
|
+
throw new PlaywrightCliPolicyError("path-empty", "empty output path");
|
|
256
|
+
}
|
|
257
|
+
if (path.win32.isAbsolute(candidate) || path.posix.isAbsolute(candidate) || /^[a-zA-Z]:\//.test(candidate)) {
|
|
258
|
+
throw new PlaywrightCliPolicyError("path-absolute", "absolute output paths are forbidden");
|
|
259
|
+
}
|
|
260
|
+
if (candidate.split("/").includes("..")) {
|
|
261
|
+
throw new PlaywrightCliPolicyError("path-traversal", "path traversal is forbidden");
|
|
262
|
+
}
|
|
263
|
+
// Simple relative filename → rewrite into evidenceDir
|
|
264
|
+
if (!candidate.includes("/")) {
|
|
265
|
+
return `${evidenceDir}/${candidate}`;
|
|
266
|
+
}
|
|
267
|
+
if (candidate.startsWith(`${evidenceDir}/`) || candidate === evidenceDir) {
|
|
268
|
+
return candidate;
|
|
269
|
+
}
|
|
270
|
+
if (candidate.startsWith("testcase/frontend/evidence/") && !candidate.startsWith(`${evidencePrefix}/`)) {
|
|
271
|
+
throw new PlaywrightCliPolicyError("path-cross-case", "output path escapes current case evidenceDir");
|
|
272
|
+
}
|
|
273
|
+
throw new PlaywrightCliPolicyError("path-outside-evidence", `output path must stay under ${evidenceDir}`);
|
|
274
|
+
}
|
|
275
|
+
function resolveScreenshotFilename(rawFilename, ctx) {
|
|
276
|
+
if (!rawFilename) {
|
|
277
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-empty", "screenshot --filename requires a non-empty filename");
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const resolved = resolveEvidenceRelative(rawFilename, ctx);
|
|
281
|
+
if (resolved === normalizePosix(ctx.evidenceDir).replace(/\/$/, "")) {
|
|
282
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-unsafe", "screenshot --filename must name a file within the current case evidence directory");
|
|
283
|
+
}
|
|
284
|
+
return resolved;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error instanceof PlaywrightCliPolicyError &&
|
|
288
|
+
error.code === "evidence-dir-invalid") {
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-unsafe", "screenshot filename must stay within the current case evidence directory");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function isSafeBareScreenshotFilename(value) {
|
|
295
|
+
return (!value.includes("/") &&
|
|
296
|
+
!value.includes("\\") &&
|
|
297
|
+
isSafeRelativePosix(value));
|
|
298
|
+
}
|
|
299
|
+
function isPlaywrightCliElementRef(value) {
|
|
300
|
+
return /^e[1-9]\d*$/.test(value);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* `playwright-cli screenshot [target]` accepts its output only through
|
|
304
|
+
* `--filename <file>`. Legacy single filenames are normalized rather than
|
|
305
|
+
* forwarded as positional targets; all other forms are fail-closed.
|
|
306
|
+
*/
|
|
307
|
+
function normalizeScreenshotArgs(args, ctx) {
|
|
308
|
+
let target;
|
|
309
|
+
let filename;
|
|
310
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
311
|
+
const arg = args[index];
|
|
312
|
+
if (arg === "--filename") {
|
|
313
|
+
if (filename !== undefined) {
|
|
314
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-duplicate", "screenshot accepts exactly one --filename");
|
|
315
|
+
}
|
|
316
|
+
const value = args[index + 1];
|
|
317
|
+
if (value === undefined) {
|
|
318
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-missing", "screenshot --filename requires a value");
|
|
319
|
+
}
|
|
320
|
+
if (value === "--filename" || value.startsWith("--filename=")) {
|
|
321
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-duplicate", "screenshot accepts exactly one --filename");
|
|
322
|
+
}
|
|
323
|
+
if (value.startsWith("-")) {
|
|
324
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-missing", "screenshot --filename requires a value");
|
|
325
|
+
}
|
|
326
|
+
filename = resolveScreenshotFilename(value, ctx);
|
|
327
|
+
index += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (arg.startsWith("--filename=")) {
|
|
331
|
+
if (filename !== undefined) {
|
|
332
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-duplicate", "screenshot accepts exactly one --filename");
|
|
333
|
+
}
|
|
334
|
+
filename = resolveScreenshotFilename(arg.slice("--filename=".length), ctx);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (arg.startsWith("-")) {
|
|
338
|
+
throw new PlaywrightCliPolicyError("screenshot-args-ambiguous", "screenshot accepts only an optional target and --filename");
|
|
339
|
+
}
|
|
340
|
+
if (target !== undefined) {
|
|
341
|
+
throw new PlaywrightCliPolicyError("screenshot-args-ambiguous", "screenshot accepts at most one target");
|
|
342
|
+
}
|
|
343
|
+
target = arg;
|
|
344
|
+
}
|
|
345
|
+
if (filename === undefined) {
|
|
346
|
+
if (!target || !isSafeBareScreenshotFilename(target)) {
|
|
347
|
+
throw new PlaywrightCliPolicyError("screenshot-filename-unsafe", "legacy screenshot input must be one safe bare filename");
|
|
348
|
+
}
|
|
349
|
+
filename = resolveScreenshotFilename(target, ctx);
|
|
350
|
+
target = undefined;
|
|
351
|
+
}
|
|
352
|
+
else if (target !== undefined && !isPlaywrightCliElementRef(target)) {
|
|
353
|
+
throw new PlaywrightCliPolicyError("screenshot-target-invalid", "screenshot target must be a real element ref such as e5");
|
|
354
|
+
}
|
|
355
|
+
return target ? [target, "--filename", filename] : ["--filename", filename];
|
|
356
|
+
}
|
|
357
|
+
function normalizePdfOrSnapshotArgs(command, args, ctx) {
|
|
358
|
+
let target;
|
|
359
|
+
let filename;
|
|
360
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
361
|
+
const arg = args[index];
|
|
362
|
+
if (arg === "--filename" || arg.startsWith("--filename=")) {
|
|
363
|
+
if (filename !== undefined) {
|
|
364
|
+
throw new PlaywrightCliPolicyError("output-filename-duplicate", `${command} accepts exactly one --filename`);
|
|
365
|
+
}
|
|
366
|
+
const value = arg === "--filename" ? args[++index] : arg.slice("--filename=".length);
|
|
367
|
+
if (value === undefined || value.startsWith("-")) {
|
|
368
|
+
throw new PlaywrightCliPolicyError("output-filename-missing", `${command} --filename requires a value`);
|
|
369
|
+
}
|
|
370
|
+
if (!value) {
|
|
371
|
+
throw new PlaywrightCliPolicyError("output-filename-empty", `${command} --filename requires a non-empty filename`);
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
filename = resolveEvidenceRelative(value, ctx);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
throw new PlaywrightCliPolicyError("output-filename-unsafe", `${command} filename must stay within the current case evidence directory`);
|
|
378
|
+
}
|
|
379
|
+
if (filename === normalizePosix(ctx.evidenceDir).replace(/\/$/, "")) {
|
|
380
|
+
throw new PlaywrightCliPolicyError("output-filename-unsafe", `${command} --filename must name a file`);
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (arg.startsWith("-")) {
|
|
385
|
+
throw new PlaywrightCliPolicyError("output-args-ambiguous", `${command} accepts only an optional target and --filename`);
|
|
386
|
+
}
|
|
387
|
+
if (target !== undefined || command === "pdf") {
|
|
388
|
+
throw new PlaywrightCliPolicyError("output-args-ambiguous", `${command} accepts no positional output path`);
|
|
389
|
+
}
|
|
390
|
+
target = arg;
|
|
391
|
+
}
|
|
392
|
+
if (filename === undefined) {
|
|
393
|
+
if (command === "snapshot" && (target === undefined || isPlaywrightCliElementRef(target))) {
|
|
394
|
+
return target ? [target] : [];
|
|
395
|
+
}
|
|
396
|
+
throw new PlaywrightCliPolicyError("output-filename-required", `${command} requires --filename <file>`);
|
|
397
|
+
}
|
|
398
|
+
if (target !== undefined && !isPlaywrightCliElementRef(target)) {
|
|
399
|
+
throw new PlaywrightCliPolicyError("output-target-invalid", `${command} target must be a real element ref such as e5`);
|
|
400
|
+
}
|
|
401
|
+
return target ? [target, "--filename", filename] : ["--filename", filename];
|
|
402
|
+
}
|
|
403
|
+
function rewriteArgsForPaths(command, args, ctx) {
|
|
404
|
+
const inputCommands = new Set(["upload", "drop"]);
|
|
405
|
+
const rewritten = [];
|
|
406
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
407
|
+
const arg = args[i];
|
|
408
|
+
assertNoControlMeta(arg);
|
|
409
|
+
assertNoSessionFlag(arg);
|
|
410
|
+
const eq = arg.match(/^(--?(?:filename|path|output|file|o))=(.*)$/i);
|
|
411
|
+
if (eq) {
|
|
412
|
+
if (!inputCommands.has(command)) {
|
|
413
|
+
throw new PlaywrightCliPolicyError("output-flag-forbidden", "file output flags are not supported for this command");
|
|
414
|
+
}
|
|
415
|
+
assertInputPathAllowed(eq[2], ctx);
|
|
416
|
+
rewritten.push(`${eq[1]}=${normalizePosix(eq[2])}`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const bareFlag = arg.toLowerCase();
|
|
420
|
+
if (OUTPUT_PATH_FLAGS.has(bareFlag)) {
|
|
421
|
+
const value = args[i + 1];
|
|
422
|
+
if (value === undefined) {
|
|
423
|
+
throw new PlaywrightCliPolicyError("output-flag-value-missing", "file path flag requires a value");
|
|
424
|
+
}
|
|
425
|
+
if (!inputCommands.has(command)) {
|
|
426
|
+
throw new PlaywrightCliPolicyError("output-flag-forbidden", "file output flags are not supported for this command");
|
|
427
|
+
}
|
|
428
|
+
assertNoControlMeta(value);
|
|
429
|
+
assertNoSessionFlag(value);
|
|
430
|
+
assertInputPathAllowed(value, ctx);
|
|
431
|
+
rewritten.push(arg, normalizePosix(value));
|
|
432
|
+
i += 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (inputCommands.has(command) && !arg.startsWith("-") && /\.[a-z0-9]+$/i.test(arg)) {
|
|
436
|
+
assertInputPathAllowed(arg, ctx);
|
|
437
|
+
rewritten.push(normalizePosix(arg));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
rewritten.push(arg);
|
|
441
|
+
}
|
|
442
|
+
return rewritten;
|
|
443
|
+
}
|
|
444
|
+
function assertInputPathAllowed(rawPath, ctx) {
|
|
445
|
+
const candidate = normalizePosix(rawPath);
|
|
446
|
+
if (!isSafeRelativePosix(candidate)) {
|
|
447
|
+
throw new PlaywrightCliPolicyError("input-path-unsafe", `unsafe input path: ${rawPath}`);
|
|
448
|
+
}
|
|
449
|
+
const allowedPrefixes = [
|
|
450
|
+
`testcase/frontend/evidence/${ctx.caseId}/`,
|
|
451
|
+
"testcase/frontend/cases/",
|
|
452
|
+
"testcase/frontend/rag/",
|
|
453
|
+
"testcase/frontend/fixtures/",
|
|
454
|
+
];
|
|
455
|
+
if (!allowedPrefixes.some((prefix) => candidate === prefix.slice(0, -1) || candidate.startsWith(prefix)) &&
|
|
456
|
+
!candidate.startsWith(normalizePosix(ctx.evidenceDir) + "/")) {
|
|
457
|
+
throw new PlaywrightCliPolicyError("input-path-outside", `input path outside allowed case/RAG/fixture/evidence roots: ${rawPath}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
export class PlaywrightCliPolicyError extends Error {
|
|
461
|
+
code;
|
|
462
|
+
constructor(code, message) {
|
|
463
|
+
super(message);
|
|
464
|
+
this.name = "PlaywrightCliPolicyError";
|
|
465
|
+
this.code = code;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
export function preparePlaywrightCliArgv(input, ctx) {
|
|
469
|
+
const command = String(input.command ?? "").trim();
|
|
470
|
+
if (!isPlaywrightCliCommand(command)) {
|
|
471
|
+
throw new PlaywrightCliPolicyError("command-not-allowed", `playwright-cli command not allowed: ${command || "(empty)"}`);
|
|
472
|
+
}
|
|
473
|
+
const rawArgs = Array.isArray(input.args) ? input.args.map(String) : [];
|
|
474
|
+
for (const arg of rawArgs) {
|
|
475
|
+
assertNoControlMeta(arg);
|
|
476
|
+
assertNoSessionFlag(arg);
|
|
477
|
+
}
|
|
478
|
+
let args = rawArgs;
|
|
479
|
+
if (command === "open") {
|
|
480
|
+
args = validateOpenArgs(rawArgs, ctx.baseUrl);
|
|
481
|
+
}
|
|
482
|
+
else if (command === "goto") {
|
|
483
|
+
args = rawArgs.map((arg) => {
|
|
484
|
+
assertNoControlMeta(arg);
|
|
485
|
+
assertNoSessionFlag(arg);
|
|
486
|
+
if (arg.startsWith("-")) {
|
|
487
|
+
throw new PlaywrightCliPolicyError("goto-args", "goto accepts one URL and no flags");
|
|
488
|
+
}
|
|
489
|
+
let target;
|
|
490
|
+
try {
|
|
491
|
+
target = new URL(arg, ctx.baseUrl).toString();
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
throw new PlaywrightCliPolicyError("goto-url-invalid", "goto requires a URL relative to the controller baseUrl");
|
|
495
|
+
}
|
|
496
|
+
const openLike = validateOpenArgs(["--browser=chrome", "--headed", target], ctx.baseUrl);
|
|
497
|
+
return openLike[openLike.length - 1];
|
|
498
|
+
});
|
|
499
|
+
if (args.length !== 1) {
|
|
500
|
+
throw new PlaywrightCliPolicyError("goto-args", "goto requires exactly one URL");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else if (command === "screenshot") {
|
|
504
|
+
args = normalizeScreenshotArgs(rawArgs, ctx);
|
|
505
|
+
}
|
|
506
|
+
else if (command === "pdf" || command === "snapshot") {
|
|
507
|
+
args = normalizePdfOrSnapshotArgs(command, rawArgs, ctx);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
args = rewriteArgsForPaths(command, rawArgs, ctx);
|
|
511
|
+
}
|
|
512
|
+
return { command, args };
|
|
513
|
+
}
|
|
514
|
+
/** Validate upload/drop input using real paths; symlinks and repository-root fallback fail closed. */
|
|
515
|
+
async function validatePlaywrightCliInputFiles(command, args, ctx) {
|
|
516
|
+
if (command !== "upload" && command !== "drop")
|
|
517
|
+
return;
|
|
518
|
+
if (!ctx.inputRoot) {
|
|
519
|
+
throw new PlaywrightCliPolicyError("input-root-missing", "upload/drop requires a controller-owned inputRoot");
|
|
520
|
+
}
|
|
521
|
+
const root = await realpath(path.resolve(ctx.repoRoot, ctx.inputRoot));
|
|
522
|
+
// The structured CLI syntax carries element references plus one or more file
|
|
523
|
+
// paths. Only file-shaped operands are filesystem inputs; selectors never
|
|
524
|
+
// receive a repository-root fallback. No file operand is fail-closed.
|
|
525
|
+
const candidates = args.filter((arg) => !arg.startsWith("-") && /\.[A-Za-z0-9]{1,16}$/.test(arg));
|
|
526
|
+
if (candidates.length === 0) {
|
|
527
|
+
throw new PlaywrightCliPolicyError("input-path-missing", "upload/drop requires an explicit file path under inputRoot");
|
|
528
|
+
}
|
|
529
|
+
for (const candidate of candidates) {
|
|
530
|
+
if (!isSafeRelativePosix(candidate)) {
|
|
531
|
+
throw new PlaywrightCliPolicyError("input-path-unsafe", "upload/drop input must be a safe path relative to inputRoot");
|
|
532
|
+
}
|
|
533
|
+
const lexical = path.resolve(ctx.repoRoot, candidate);
|
|
534
|
+
const relative = path.relative(root, lexical);
|
|
535
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
536
|
+
throw new PlaywrightCliPolicyError("input-path-outside", "upload/drop input escapes inputRoot");
|
|
537
|
+
}
|
|
538
|
+
const info = await lstat(lexical);
|
|
539
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
540
|
+
throw new PlaywrightCliPolicyError("input-not-regular-file", "upload/drop input must be a regular non-symlink file");
|
|
541
|
+
}
|
|
542
|
+
const resolved = await realpath(lexical);
|
|
543
|
+
const resolvedRelative = path.relative(root, resolved);
|
|
544
|
+
if (resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) {
|
|
545
|
+
throw new PlaywrightCliPolicyError("input-realpath-escape", "upload/drop input realpath escapes inputRoot");
|
|
546
|
+
}
|
|
547
|
+
if (!(await stat(resolved)).isFile()) {
|
|
548
|
+
throw new PlaywrightCliPolicyError("input-not-regular-file", "upload/drop input must resolve to a regular file");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function pathIsContained(root, candidate) {
|
|
553
|
+
const relative = path.relative(root, candidate);
|
|
554
|
+
return !(relative === ".." ||
|
|
555
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
556
|
+
path.isAbsolute(relative));
|
|
557
|
+
}
|
|
558
|
+
function outputRealpathUnsafe(message) {
|
|
559
|
+
return new PlaywrightCliPolicyError("output-realpath-unsafe", message);
|
|
560
|
+
}
|
|
561
|
+
async function lstatOutputPath(candidate) {
|
|
562
|
+
try {
|
|
563
|
+
return await lstat(candidate);
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error.code === "ENOENT")
|
|
567
|
+
return undefined;
|
|
568
|
+
throw outputRealpathUnsafe(`cannot inspect output path: ${candidate}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function canonicalOutputFilename(command, args) {
|
|
572
|
+
if (command !== "screenshot" && command !== "pdf" && command !== "snapshot") {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
const index = args.indexOf("--filename");
|
|
576
|
+
if (index < 0) {
|
|
577
|
+
if (command === "snapshot")
|
|
578
|
+
return undefined;
|
|
579
|
+
throw outputRealpathUnsafe("output command is missing canonical --filename argv");
|
|
580
|
+
}
|
|
581
|
+
const filename = args[index + 1];
|
|
582
|
+
if (!filename || index !== args.length - 2) {
|
|
583
|
+
throw outputRealpathUnsafe("output command did not produce canonical --filename argv");
|
|
584
|
+
}
|
|
585
|
+
return filename;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Verify controller-owned output paths immediately before runner/launcher use.
|
|
589
|
+
* This rejects pre-existing symlinks and validates both lexical and realpath
|
|
590
|
+
* containment; it intentionally cannot eliminate a post-check filesystem race.
|
|
591
|
+
*/
|
|
592
|
+
async function validatePlaywrightCliOutputPath(command, args, ctx) {
|
|
593
|
+
const outputFilename = canonicalOutputFilename(command, args);
|
|
594
|
+
if (!outputFilename)
|
|
595
|
+
return;
|
|
596
|
+
const repoRoot = path.resolve(ctx.repoRoot);
|
|
597
|
+
const caseEvidenceRoot = path.resolve(repoRoot, "testcase", "frontend", "evidence", ctx.caseId);
|
|
598
|
+
const evidenceDir = path.resolve(repoRoot, ctx.evidenceDir);
|
|
599
|
+
const target = path.resolve(repoRoot, outputFilename);
|
|
600
|
+
if (!pathIsContained(repoRoot, caseEvidenceRoot) ||
|
|
601
|
+
!pathIsContained(caseEvidenceRoot, evidenceDir) ||
|
|
602
|
+
!pathIsContained(evidenceDir, target) ||
|
|
603
|
+
target === evidenceDir) {
|
|
604
|
+
throw outputRealpathUnsafe("output path escapes the current case evidence directory");
|
|
605
|
+
}
|
|
606
|
+
for (const directory of [repoRoot, caseEvidenceRoot, evidenceDir]) {
|
|
607
|
+
const info = await lstatOutputPath(directory);
|
|
608
|
+
if (!info || info.isSymbolicLink() || !info.isDirectory()) {
|
|
609
|
+
throw outputRealpathUnsafe("output evidence directory must be an existing non-symlink directory");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const relativeParts = path.relative(repoRoot, target).split(path.sep).filter(Boolean);
|
|
613
|
+
let current = repoRoot;
|
|
614
|
+
let nearestExistingDirectory = repoRoot;
|
|
615
|
+
let targetExists = false;
|
|
616
|
+
for (let index = 0; index < relativeParts.length; index += 1) {
|
|
617
|
+
current = path.join(current, relativeParts[index]);
|
|
618
|
+
const info = await lstatOutputPath(current);
|
|
619
|
+
if (!info)
|
|
620
|
+
break;
|
|
621
|
+
if (info.isSymbolicLink()) {
|
|
622
|
+
throw outputRealpathUnsafe("output path has a symlink ancestor or target");
|
|
623
|
+
}
|
|
624
|
+
if (index === relativeParts.length - 1) {
|
|
625
|
+
targetExists = true;
|
|
626
|
+
if (!info.isFile()) {
|
|
627
|
+
throw outputRealpathUnsafe("output target must be a regular file when it exists");
|
|
628
|
+
}
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
if (!info.isDirectory()) {
|
|
632
|
+
throw outputRealpathUnsafe("output path has a non-directory ancestor");
|
|
633
|
+
}
|
|
634
|
+
nearestExistingDirectory = current;
|
|
635
|
+
}
|
|
636
|
+
let repoReal;
|
|
637
|
+
let caseEvidenceReal;
|
|
638
|
+
let evidenceReal;
|
|
639
|
+
let nearestExistingReal;
|
|
640
|
+
try {
|
|
641
|
+
[repoReal, caseEvidenceReal, evidenceReal, nearestExistingReal] = await Promise.all([
|
|
642
|
+
realpath(repoRoot),
|
|
643
|
+
realpath(caseEvidenceRoot),
|
|
644
|
+
realpath(evidenceDir),
|
|
645
|
+
realpath(nearestExistingDirectory),
|
|
646
|
+
]);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
throw outputRealpathUnsafe("cannot resolve output containment realpath");
|
|
650
|
+
}
|
|
651
|
+
if (!pathIsContained(repoReal, caseEvidenceReal) ||
|
|
652
|
+
!pathIsContained(repoReal, evidenceReal) ||
|
|
653
|
+
!pathIsContained(caseEvidenceReal, evidenceReal) ||
|
|
654
|
+
!pathIsContained(evidenceReal, nearestExistingReal)) {
|
|
655
|
+
throw outputRealpathUnsafe("output realpath escapes the current case evidence directory");
|
|
656
|
+
}
|
|
657
|
+
if (targetExists) {
|
|
658
|
+
let targetReal;
|
|
659
|
+
try {
|
|
660
|
+
targetReal = await realpath(target);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
throw outputRealpathUnsafe("cannot resolve existing output target realpath");
|
|
664
|
+
}
|
|
665
|
+
if (!pathIsContained(evidenceReal, targetReal) || !pathIsContained(repoReal, targetReal)) {
|
|
666
|
+
throw outputRealpathUnsafe("existing output target realpath escapes containment");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
export async function defaultPlaywrightCliCommandRunner(input) {
|
|
671
|
+
const maxBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
672
|
+
return await new Promise((resolve) => {
|
|
673
|
+
const child = spawn(input.executable, input.argv, {
|
|
674
|
+
cwd: input.cwd,
|
|
675
|
+
env: input.env,
|
|
676
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
677
|
+
...processTreeSpawnOptions(),
|
|
678
|
+
});
|
|
679
|
+
let stdout = "";
|
|
680
|
+
let stderr = "";
|
|
681
|
+
let stdoutBytes = 0;
|
|
682
|
+
let stderrBytes = 0;
|
|
683
|
+
let settled = false;
|
|
684
|
+
let timedOut = false;
|
|
685
|
+
const finish = (exitCode, signalName) => {
|
|
686
|
+
if (settled)
|
|
687
|
+
return;
|
|
688
|
+
settled = true;
|
|
689
|
+
if (timer)
|
|
690
|
+
clearTimeout(timer);
|
|
691
|
+
if (input.signal)
|
|
692
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
693
|
+
resolve({
|
|
694
|
+
exitCode,
|
|
695
|
+
stdout,
|
|
696
|
+
stderr,
|
|
697
|
+
timedOut,
|
|
698
|
+
signal: signalName ?? null,
|
|
699
|
+
});
|
|
700
|
+
};
|
|
701
|
+
const onAbort = () => {
|
|
702
|
+
timedOut = false;
|
|
703
|
+
terminateProcessTree(child, "SIGKILL");
|
|
704
|
+
finish(null, "abort");
|
|
705
|
+
};
|
|
706
|
+
const timer = setTimeout(() => {
|
|
707
|
+
timedOut = true;
|
|
708
|
+
terminateProcessTree(child, "SIGKILL");
|
|
709
|
+
}, input.timeoutMs);
|
|
710
|
+
if (input.signal) {
|
|
711
|
+
if (input.signal.aborted)
|
|
712
|
+
onAbort();
|
|
713
|
+
else
|
|
714
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
715
|
+
}
|
|
716
|
+
child.stdout?.on("data", (chunk) => {
|
|
717
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
718
|
+
if (stdoutBytes >= maxBytes)
|
|
719
|
+
return;
|
|
720
|
+
const slice = buf.subarray(0, Math.max(0, maxBytes - stdoutBytes));
|
|
721
|
+
stdoutBytes += slice.length;
|
|
722
|
+
stdout += slice.toString("utf8");
|
|
723
|
+
});
|
|
724
|
+
child.stderr?.on("data", (chunk) => {
|
|
725
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
726
|
+
if (stderrBytes >= maxBytes)
|
|
727
|
+
return;
|
|
728
|
+
const slice = buf.subarray(0, Math.max(0, maxBytes - stderrBytes));
|
|
729
|
+
stderrBytes += slice.length;
|
|
730
|
+
stderr += slice.toString("utf8");
|
|
731
|
+
});
|
|
732
|
+
child.on("error", (error) => {
|
|
733
|
+
stderr = stderr || error.message;
|
|
734
|
+
finish(null, null);
|
|
735
|
+
});
|
|
736
|
+
child.on("close", (code, signalName) => {
|
|
737
|
+
finish(code, signalName);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
let receiptSequenceByNode = new Map();
|
|
742
|
+
/** Test hook: reset per-node receipt sequence counters. */
|
|
743
|
+
export function resetPlaywrightCliReceiptSequencesForTests() {
|
|
744
|
+
receiptSequenceByNode = new Map();
|
|
745
|
+
}
|
|
746
|
+
async function appendReceipt(ctx, receipt) {
|
|
747
|
+
const receiptPath = resolvePlaywrightCliReceiptPath(ctx.runDir, ctx.nodeId);
|
|
748
|
+
await mkdir(path.dirname(receiptPath), { recursive: true });
|
|
749
|
+
await appendFile(receiptPath, `${JSON.stringify(receipt)}\n`, "utf8");
|
|
750
|
+
}
|
|
751
|
+
function nextSequence(nodeId) {
|
|
752
|
+
const current = receiptSequenceByNode.get(nodeId) ?? 0;
|
|
753
|
+
const next = current + 1;
|
|
754
|
+
receiptSequenceByNode.set(nodeId, next);
|
|
755
|
+
return next;
|
|
756
|
+
}
|
|
757
|
+
export async function executePlaywrightCliCommand(input, ctx, signal) {
|
|
758
|
+
const startedAt = new Date();
|
|
759
|
+
const sequence = nextSequence(ctx.nodeId);
|
|
760
|
+
const timeoutSeconds = Math.max(1, Math.min(600, Number(input.timeoutSeconds ?? ctx.defaultTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) ||
|
|
761
|
+
DEFAULT_TIMEOUT_SECONDS));
|
|
762
|
+
let command = String(input.command ?? "");
|
|
763
|
+
let args = [];
|
|
764
|
+
let errorClass;
|
|
765
|
+
try {
|
|
766
|
+
const prepared = preparePlaywrightCliArgv(input, ctx);
|
|
767
|
+
command = prepared.command;
|
|
768
|
+
args = prepared.args;
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
errorClass =
|
|
772
|
+
error instanceof PlaywrightCliPolicyError ? error.code : "prepare-failed";
|
|
773
|
+
const finishedAt = new Date();
|
|
774
|
+
const receipt = {
|
|
775
|
+
schemaVersion: 1,
|
|
776
|
+
caseId: ctx.caseId,
|
|
777
|
+
sequence,
|
|
778
|
+
command,
|
|
779
|
+
argsRedacted: redactPlaywrightCliArgs(command, Array.isArray(input.args) ? input.args.map(String) : []),
|
|
780
|
+
startedAt: startedAt.toISOString(),
|
|
781
|
+
finishedAt: finishedAt.toISOString(),
|
|
782
|
+
exitCode: null,
|
|
783
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
784
|
+
outputSha256: createHash("sha256").update("").digest("hex"),
|
|
785
|
+
toolVersion: ctx.toolVersion ?? "unknown",
|
|
786
|
+
errorClass,
|
|
787
|
+
};
|
|
788
|
+
await appendReceipt(ctx, receipt);
|
|
789
|
+
return {
|
|
790
|
+
ok: false,
|
|
791
|
+
exitCode: null,
|
|
792
|
+
stdout: "",
|
|
793
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
794
|
+
timedOut: false,
|
|
795
|
+
receipt,
|
|
796
|
+
errorClass,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
let executable = PLAYWRIGHT_CLI_EXECUTABLE;
|
|
800
|
+
let argv = [command, ...args];
|
|
801
|
+
try {
|
|
802
|
+
await validatePlaywrightCliOutputPath(command, args, ctx);
|
|
803
|
+
await validatePlaywrightCliInputFiles(command, args, ctx);
|
|
804
|
+
// Test runners are an in-memory boundary and never spawn; production always
|
|
805
|
+
// resolves the verified JavaScript launcher rather than a PATH/.cmd shim.
|
|
806
|
+
if (!ctx.runCommand) {
|
|
807
|
+
const launcher = resolvePlaywrightCliLauncher(ctx.repoRoot);
|
|
808
|
+
executable = launcher.executable;
|
|
809
|
+
argv = [...launcher.argvPrefix, command, ...args];
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
errorClass = error instanceof PlaywrightCliPolicyError ? error.code : "playwright-cli-unavailable";
|
|
814
|
+
const finishedAt = new Date();
|
|
815
|
+
const receipt = {
|
|
816
|
+
schemaVersion: 1, caseId: ctx.caseId, sequence, command,
|
|
817
|
+
argsRedacted: redactPlaywrightCliArgs(command, args), startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(),
|
|
818
|
+
exitCode: null, durationMs: finishedAt.getTime() - startedAt.getTime(), outputSha256: createHash("sha256").update("").digest("hex"), toolVersion: ctx.toolVersion ?? "unknown", errorClass,
|
|
819
|
+
};
|
|
820
|
+
await appendReceipt(ctx, receipt);
|
|
821
|
+
return { ok: false, exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, receipt, errorClass };
|
|
822
|
+
}
|
|
823
|
+
const runner = ctx.runCommand ?? defaultPlaywrightCliCommandRunner;
|
|
824
|
+
let runResult;
|
|
825
|
+
try {
|
|
826
|
+
runResult = await runner({
|
|
827
|
+
executable,
|
|
828
|
+
argv,
|
|
829
|
+
cwd: path.resolve(ctx.repoRoot),
|
|
830
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
831
|
+
signal,
|
|
832
|
+
env: {
|
|
833
|
+
...process.env,
|
|
834
|
+
CI: process.env.CI ?? "1",
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
runResult = { exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, signal: null };
|
|
840
|
+
}
|
|
841
|
+
const finishedAt = new Date();
|
|
842
|
+
const combined = `${runResult.stdout}\n${runResult.stderr}`;
|
|
843
|
+
const receipt = {
|
|
844
|
+
schemaVersion: 1,
|
|
845
|
+
caseId: ctx.caseId,
|
|
846
|
+
sequence,
|
|
847
|
+
command,
|
|
848
|
+
argsRedacted: redactPlaywrightCliArgs(command, args),
|
|
849
|
+
startedAt: startedAt.toISOString(),
|
|
850
|
+
finishedAt: finishedAt.toISOString(),
|
|
851
|
+
exitCode: runResult.exitCode,
|
|
852
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
853
|
+
outputSha256: createHash("sha256").update(combined).digest("hex"),
|
|
854
|
+
toolVersion: ctx.toolVersion ?? "unknown",
|
|
855
|
+
timedOut: runResult.timedOut || undefined,
|
|
856
|
+
errorClass: runResult.timedOut
|
|
857
|
+
? "timeout"
|
|
858
|
+
: runResult.signal === "abort"
|
|
859
|
+
? "abort"
|
|
860
|
+
: runResult.exitCode === 0
|
|
861
|
+
? undefined
|
|
862
|
+
: "nonzero-exit",
|
|
863
|
+
};
|
|
864
|
+
await appendReceipt(ctx, receipt);
|
|
865
|
+
return {
|
|
866
|
+
ok: runResult.exitCode === 0 && !runResult.timedOut,
|
|
867
|
+
exitCode: runResult.exitCode,
|
|
868
|
+
stdout: runResult.stdout,
|
|
869
|
+
stderr: runResult.stderr,
|
|
870
|
+
timedOut: runResult.timedOut,
|
|
871
|
+
receipt,
|
|
872
|
+
errorClass: receipt.errorClass,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Best-effort default-session close for child finally / pre-start cleanup.
|
|
877
|
+
* Missing session must not throw.
|
|
878
|
+
*/
|
|
879
|
+
export async function cleanupPlaywrightCliDefaultSession(ctx, signal, cleanupPhase = "post-execution") {
|
|
880
|
+
// Best-effort: missing/no-op session must not throw to callers.
|
|
881
|
+
const result = await executePlaywrightCliCommand({ command: "close", args: [] }, ctx, signal);
|
|
882
|
+
// Persist the controller-owned lifecycle phase for every cleanup attempt so
|
|
883
|
+
// timeout/abort/spawn/nonzero failures remain auditable. Only a successful
|
|
884
|
+
// marker is treated as cleanup-confirmed by summarizePlaywrightCliReceipts.
|
|
885
|
+
const marker = {
|
|
886
|
+
...result.receipt,
|
|
887
|
+
sequence: nextSequence(ctx.nodeId),
|
|
888
|
+
controllerCleanup: true,
|
|
889
|
+
cleanupPhase,
|
|
890
|
+
};
|
|
891
|
+
await appendReceipt(ctx, marker);
|
|
892
|
+
return { ...result, receipt: marker };
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Structured custom tool for Pi SDK. Model only passes {command, args?, timeoutSeconds?}.
|
|
896
|
+
*/
|
|
897
|
+
export async function createPlaywrightCliTool(ctx) {
|
|
898
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
899
|
+
import("typebox"),
|
|
900
|
+
import("@earendil-works/pi-coding-agent"),
|
|
901
|
+
]);
|
|
902
|
+
const parameters = Type.Object({
|
|
903
|
+
command: Type.String({
|
|
904
|
+
description: `Playwright CLI command name. Allowed: ${PLAYWRIGHT_CLI_ALLOWED_COMMANDS.join(", ")}`,
|
|
905
|
+
}),
|
|
906
|
+
args: Type.Optional(Type.Array(Type.String(), { description: "Positional/flag args (not a shell string)" })),
|
|
907
|
+
timeoutSeconds: Type.Optional(Type.Number({ description: "Per-command timeout seconds (default 60, max 600)" })),
|
|
908
|
+
}, { additionalProperties: false });
|
|
909
|
+
return defineTool({
|
|
910
|
+
name: "playwright_cli",
|
|
911
|
+
label: "playwright_cli",
|
|
912
|
+
description: "Execute a controlled playwright-cli command. Fixed executable; no shell chaining. Use for open/snapshot/interaction/close only.",
|
|
913
|
+
parameters,
|
|
914
|
+
async execute(_toolCallId, params, signal, _onUpdate, _extensionContext) {
|
|
915
|
+
const result = await executePlaywrightCliCommand(params, ctx, signal);
|
|
916
|
+
const text = [
|
|
917
|
+
`ok=${result.ok}`,
|
|
918
|
+
`command=${result.receipt.command}`,
|
|
919
|
+
`exitCode=${result.exitCode}`,
|
|
920
|
+
result.timedOut ? "timedOut=true" : "",
|
|
921
|
+
result.errorClass ? `errorClass=${result.errorClass}` : "",
|
|
922
|
+
result.stdout ? `stdout:\n${result.stdout}` : "",
|
|
923
|
+
result.stderr ? `stderr:\n${result.stderr}` : "",
|
|
924
|
+
]
|
|
925
|
+
.filter(Boolean)
|
|
926
|
+
.join("\n");
|
|
927
|
+
return {
|
|
928
|
+
content: [{ type: "text", text }],
|
|
929
|
+
details: result,
|
|
930
|
+
};
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
export const PI_COMMAND_CAPABILITY_REGISTRY = {
|
|
935
|
+
"playwright-cli": createPlaywrightCliTool,
|
|
936
|
+
};
|
|
937
|
+
export function resolveCaseIdFromWriteSet(writeSet) {
|
|
938
|
+
for (const entry of writeSet ?? []) {
|
|
939
|
+
const normalized = normalizePosix(entry);
|
|
940
|
+
const match = normalized.match(/^testcase\/frontend\/evidence\/([^/]+)(?:\/\*\*)?$/);
|
|
941
|
+
if (match?.[1] && match[1] !== "**" && match[1] !== "*") {
|
|
942
|
+
return match[1];
|
|
943
|
+
}
|
|
944
|
+
const nested = normalized.match(/^testcase\/frontend\/evidence\/([^/]+)\//);
|
|
945
|
+
if (nested?.[1])
|
|
946
|
+
return nested[1];
|
|
947
|
+
}
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
export function resolveEvidenceDirFromWriteSet(writeSet) {
|
|
951
|
+
const caseId = resolveCaseIdFromWriteSet(writeSet);
|
|
952
|
+
if (!caseId)
|
|
953
|
+
return undefined;
|
|
954
|
+
return `testcase/frontend/evidence/${caseId}`;
|
|
955
|
+
}
|