ptywright 0.4.0 → 0.6.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/README.md +38 -31
- package/dist/agent.mjs +2 -2
- package/dist/bin/ptywright.mjs +1 -1
- package/dist/{cli-C40H_ElC.mjs → cli-IXmvK56U.mjs} +2519 -2472
- package/dist/cli.mjs +1 -1
- package/dist/config.mjs +1 -1
- package/dist/data-sdbf3IDh.mjs +18 -0
- package/dist/env-DPYHo-zH.mjs +36 -0
- package/dist/index.mjs +1 -1
- package/dist/manifest_files-DW80c1H7.mjs +77 -0
- package/dist/mcp.mjs +1 -1
- package/dist/pty-cassette.mjs +2 -1
- package/dist/{pty_like-DqCo7XdB.mjs → pty_like-DWIlWGgA.mjs} +4 -20
- package/dist/{runner-zApMYWZx.mjs → runner-BHXXwxYp.mjs} +1578 -1423
- package/dist/{runner-CembqDgJ.mjs → runner-GIEf0C6d.mjs} +2434 -1134
- package/dist/script.mjs +1 -1
- package/dist/{server-h--2U0Ic.mjs → server-ceZ1-s_J.mjs} +2643 -2527
- package/dist/session.mjs +1 -1
- package/dist/style-BtIUv5H0.mjs +65 -0
- package/dist/{terminal_session-DopC7Xg6.mjs → terminal_session-MX_vWpRG.mjs} +322 -364
- package/package.json +2 -1
- package/dist/{config-B0r-JCFI.mjs → config-bGg636EW.mjs} +1 -1
|
@@ -1,223 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as sameArgv, n as validateManifestFiles, r as formatZodIssues, t as collectManifestFiles } from "./manifest_files-DW80c1H7.mjs";
|
|
2
|
+
import { t as envTruthy } from "./env-DPYHo-zH.mjs";
|
|
3
|
+
import { t as base64ToBytes } from "./data-sdbf3IDh.mjs";
|
|
4
|
+
import { a as styleKey, i as isDefaultStyle, n as extractStyle, r as findMeaningfulEndCol, t as DEFAULT_STYLE } from "./style-BtIUv5H0.mjs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
8
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import {
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
|
-
import { chromium } from "playwright";
|
|
9
|
+
import { z } from "zod";
|
|
7
10
|
import { createServer } from "node:http";
|
|
11
|
+
import { chromium } from "playwright";
|
|
12
|
+
import { Terminal } from "@xterm/headless";
|
|
8
13
|
import { spawn } from "node:child_process";
|
|
9
|
-
//#region src/agent/manifest.ts
|
|
10
|
-
const AGENT_MANIFEST_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json";
|
|
11
|
-
const AGENT_MANIFEST_FILE_NAME = "ptywright-agent.manifest.json";
|
|
12
|
-
const agentManifestKindSchema = z.enum([
|
|
13
|
-
"run",
|
|
14
|
-
"replay-suite",
|
|
15
|
-
"check",
|
|
16
|
-
"promote"
|
|
17
|
-
]);
|
|
18
|
-
const agentManifestFileKindSchema = z.enum([
|
|
19
|
-
"flow",
|
|
20
|
-
"cassette",
|
|
21
|
-
"run-record",
|
|
22
|
-
"replay-summary",
|
|
23
|
-
"check-summary",
|
|
24
|
-
"promote-summary",
|
|
25
|
-
"report",
|
|
26
|
-
"terminal",
|
|
27
|
-
"dom",
|
|
28
|
-
"screenshot",
|
|
29
|
-
"diff"
|
|
30
|
-
]);
|
|
31
|
-
const agentManifestCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
|
|
32
|
-
const agentManifestValidationStageSchema = z.object({
|
|
33
|
-
name: z.string().min(1),
|
|
34
|
-
ok: z.boolean(),
|
|
35
|
-
totalCount: z.number().int().nonnegative(),
|
|
36
|
-
failureCount: z.number().int().nonnegative()
|
|
37
|
-
}).strict();
|
|
38
|
-
const agentManifestValidationSchema = z.object({
|
|
39
|
-
ok: z.boolean(),
|
|
40
|
-
stages: z.array(agentManifestValidationStageSchema)
|
|
41
|
-
}).strict().superRefine((validation, ctx) => {
|
|
42
|
-
const ok = validation.stages.every((stage) => stage.ok && stage.failureCount === 0);
|
|
43
|
-
if (validation.ok !== ok) ctx.addIssue({
|
|
44
|
-
code: z.ZodIssueCode.custom,
|
|
45
|
-
path: ["ok"],
|
|
46
|
-
message: "validation ok must match validation stages"
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
const agentManifestFileSchema = z.object({
|
|
50
|
-
path: z.string().min(1),
|
|
51
|
-
kind: agentManifestFileKindSchema,
|
|
52
|
-
role: z.string().min(1).optional(),
|
|
53
|
-
ok: z.boolean().optional(),
|
|
54
|
-
bytes: z.number().int().nonnegative(),
|
|
55
|
-
sha256: z.string().regex(/^[a-f0-9]{64}$/)
|
|
56
|
-
}).strict();
|
|
57
|
-
const agentManifestSchema = z.object({
|
|
58
|
-
$schema: z.string().optional(),
|
|
59
|
-
version: z.literal(1),
|
|
60
|
-
kind: agentManifestKindSchema,
|
|
61
|
-
ok: z.boolean(),
|
|
62
|
-
generatedAt: z.string().min(1),
|
|
63
|
-
rootDir: z.string().min(1),
|
|
64
|
-
primaryPath: z.string().min(1),
|
|
65
|
-
commands: z.record(agentManifestCommandSchema),
|
|
66
|
-
validation: agentManifestValidationSchema.optional(),
|
|
67
|
-
files: z.array(agentManifestFileSchema)
|
|
68
|
-
}).strict().superRefine((manifest, ctx) => {
|
|
69
|
-
const seen = /* @__PURE__ */ new Set();
|
|
70
|
-
for (const file of manifest.files) {
|
|
71
|
-
if (seen.has(file.path)) ctx.addIssue({
|
|
72
|
-
code: z.ZodIssueCode.custom,
|
|
73
|
-
path: ["files"],
|
|
74
|
-
message: `duplicate manifest file path: ${file.path}`
|
|
75
|
-
});
|
|
76
|
-
seen.add(file.path);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
function agentManifestPath(rootDir) {
|
|
80
|
-
return join(rootDir, AGENT_MANIFEST_FILE_NAME);
|
|
81
|
-
}
|
|
82
|
-
function createAgentManifest(options) {
|
|
83
|
-
return normalizeAgentManifest({
|
|
84
|
-
$schema: AGENT_MANIFEST_SCHEMA_URL,
|
|
85
|
-
version: 1,
|
|
86
|
-
kind: options.kind,
|
|
87
|
-
ok: options.ok,
|
|
88
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
89
|
-
rootDir: options.rootDir,
|
|
90
|
-
primaryPath: options.primaryPath,
|
|
91
|
-
commands: options.commands,
|
|
92
|
-
validation: options.validation,
|
|
93
|
-
files: collectManifestFiles(options.files, options.rootDir)
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
function readAgentManifestPath(path) {
|
|
97
|
-
return normalizeAgentManifest(JSON.parse(readFileSync(path, "utf8")));
|
|
98
|
-
}
|
|
99
|
-
function writeAgentManifestPath(path, options) {
|
|
100
|
-
const manifest = createAgentManifest(options);
|
|
101
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
102
|
-
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
103
|
-
return manifest;
|
|
104
|
-
}
|
|
105
|
-
function normalizeAgentManifest(input) {
|
|
106
|
-
try {
|
|
107
|
-
const parsed = agentManifestSchema.parse(input);
|
|
108
|
-
return {
|
|
109
|
-
...parsed,
|
|
110
|
-
$schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json"
|
|
111
|
-
};
|
|
112
|
-
} catch (error) {
|
|
113
|
-
if (error instanceof z.ZodError) throw new Error(`invalid agent manifest: ${formatZodIssues$1(error)}`);
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function validateAgentManifestFiles(manifest, manifestPath) {
|
|
118
|
-
const failures = [];
|
|
119
|
-
const baseDir = manifestPath ? dirname(resolve(process.cwd(), manifestPath)) : resolve(process.cwd(), manifest.rootDir);
|
|
120
|
-
for (const file of manifest.files) {
|
|
121
|
-
let current = null;
|
|
122
|
-
try {
|
|
123
|
-
current = readManifestFile(file, baseDir);
|
|
124
|
-
} catch (error) {
|
|
125
|
-
failures.push(`${file.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (current.bytes !== file.bytes) failures.push(`${file.path}: bytes ${current.bytes} !== ${file.bytes}`);
|
|
129
|
-
if (current.sha256 !== file.sha256) failures.push(`${file.path}: sha256 ${current.sha256} !== ${file.sha256}`);
|
|
130
|
-
}
|
|
131
|
-
if (failures.length > 0) throw new Error(`invalid agent manifest files: ${failures.join("; ")}`);
|
|
132
|
-
}
|
|
133
|
-
function isAgentManifestLike(input) {
|
|
134
|
-
return typeof input === "object" && input !== null && input.version === 1 && typeof input.kind === "string" && Array.isArray(input.files) && typeof input.commands === "object";
|
|
135
|
-
}
|
|
136
|
-
function collectManifestFiles(files, rootDir) {
|
|
137
|
-
const out = [];
|
|
138
|
-
const seen = /* @__PURE__ */ new Set();
|
|
139
|
-
const rootAbs = resolve(process.cwd(), rootDir);
|
|
140
|
-
for (const file of files) {
|
|
141
|
-
if (!file.path || seen.has(file.path)) continue;
|
|
142
|
-
seen.add(file.path);
|
|
143
|
-
try {
|
|
144
|
-
out.push(readManifestFile(file, rootAbs, { portableRoot: true }));
|
|
145
|
-
} catch {}
|
|
146
|
-
}
|
|
147
|
-
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
148
|
-
}
|
|
149
|
-
function readManifestFile(file, baseDir, options = {}) {
|
|
150
|
-
if (!file.path) throw new Error("missing file path");
|
|
151
|
-
const absPath = isAbsolute(file.path) ? file.path : resolve(options.portableRoot ? process.cwd() : baseDir, file.path);
|
|
152
|
-
if (!statSync(absPath).isFile()) throw new Error("not a file");
|
|
153
|
-
const bytes = readFileSync(absPath);
|
|
154
|
-
return {
|
|
155
|
-
path: options.portableRoot ? portableManifestPath(absPath, baseDir) : file.path,
|
|
156
|
-
kind: file.kind,
|
|
157
|
-
role: file.role,
|
|
158
|
-
ok: file.ok,
|
|
159
|
-
bytes: bytes.byteLength,
|
|
160
|
-
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
function portableManifestPath(absPath, rootAbs) {
|
|
164
|
-
const rel = relative(rootAbs, absPath);
|
|
165
|
-
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
|
|
166
|
-
return absPath;
|
|
167
|
-
}
|
|
168
|
-
function formatZodIssues$1(error) {
|
|
169
|
-
return error.issues.map((issue) => {
|
|
170
|
-
return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
171
|
-
}).join("; ");
|
|
172
|
-
}
|
|
173
|
-
//#endregion
|
|
174
|
-
//#region src/agent/browser.ts
|
|
175
|
-
async function launchAgentBrowser(args) {
|
|
176
|
-
let lastError;
|
|
177
|
-
for (let attempt = 0; attempt < 5; attempt += 1) try {
|
|
178
|
-
const browser = await chromium.launch({ headless: args.headless });
|
|
179
|
-
await verifyBrowserLaunch(browser);
|
|
180
|
-
return browser;
|
|
181
|
-
} catch (error) {
|
|
182
|
-
lastError = error;
|
|
183
|
-
if (!isTransientBrowserLaunchError(error) || attempt === 4) throw error;
|
|
184
|
-
await new Promise((resolveRetry) => setTimeout(resolveRetry, 250 * (attempt + 1)));
|
|
185
|
-
}
|
|
186
|
-
throw lastError;
|
|
187
|
-
}
|
|
188
|
-
async function verifyBrowserLaunch(browser) {
|
|
189
|
-
const context = await browser.newContext();
|
|
190
|
-
try {
|
|
191
|
-
const page = await context.newPage();
|
|
192
|
-
await page.goto("data:text/html,<title>ptywright</title>", { waitUntil: "load" });
|
|
193
|
-
await page.close();
|
|
194
|
-
} catch (error) {
|
|
195
|
-
await browser.close();
|
|
196
|
-
throw error;
|
|
197
|
-
} finally {
|
|
198
|
-
await context.close().catch(() => void 0);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
function isTransientBrowserLaunchError(error) {
|
|
202
|
-
const fields = errorFields(error);
|
|
203
|
-
return fields.message.includes("Failed to connect") || fields.message.includes("Target page, context or browser has been closed") || fields.message.includes("browserType.launch") || fields.code === "ENOENT" || fields.code === "ECONNREFUSED" || fields.syscall === "connect";
|
|
204
|
-
}
|
|
205
|
-
function errorFields(error) {
|
|
206
|
-
const record = typeof error === "object" && error !== null ? error : {};
|
|
207
|
-
return {
|
|
208
|
-
message: error instanceof Error ? error.message : String(error),
|
|
209
|
-
code: typeof record.code === "string" ? record.code : "",
|
|
210
|
-
syscall: typeof record.syscall === "string" ? record.syscall : ""
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
//#endregion
|
|
214
14
|
//#region src/agent/normalize.ts
|
|
215
|
-
function applyAgentMasks(input, rules = []) {
|
|
15
|
+
function applyAgentMasks(input, rules = [], options = {}) {
|
|
216
16
|
let out = input;
|
|
217
17
|
for (const rule of rules) {
|
|
218
18
|
const flags = rule.flags ?? "g";
|
|
219
19
|
const regex = new RegExp(rule.regex, flags.includes("g") ? flags : `${flags}g`);
|
|
220
|
-
const replacement = rule.replacement ?? "<masked>";
|
|
20
|
+
const replacement = options.replacement?.(rule.replacement ?? "<masked>") ?? rule.replacement ?? "<masked>";
|
|
221
21
|
out = out.replace(regex, (match) => {
|
|
222
22
|
if (!rule.preserveLength) return replacement;
|
|
223
23
|
if (replacement.length === match.length) return replacement;
|
|
@@ -234,7 +34,7 @@ function normalizeTerminalText(input, rules = []) {
|
|
|
234
34
|
return lines.join("\n");
|
|
235
35
|
}
|
|
236
36
|
function normalizeDomSnapshot(input, rules = []) {
|
|
237
|
-
return applyAgentMasks(input.replace(/\sdata-v-[a-z0-9-]+="[^"]*"/g, "").replace(/\sstyle="[^"]*--term-(?:cell-width|row-height):[^"]*"/g, "").replace(/\s+/g, " ").replace(/>\s+</g, "><").replace(/<div class="[^"]*\bterm-row\b[^"]*\bterm-scrollback-row\b[^"]*"[^>]*><span[^>]*><\/span><\/div>/g, "").trim(), rules);
|
|
37
|
+
return applyAgentMasks(input.replace(/\sdata-v-[a-z0-9-]+="[^"]*"/g, "").replace(/\sstyle="[^"]*--term-(?:cell-width|row-height):[^"]*"/g, "").replace(/\s+/g, " ").replace(/>\s+</g, "><").replace(/<div class="[^"]*\bterm-row\b[^"]*\bterm-scrollback-row\b[^"]*"[^>]*><span[^>]*><\/span><\/div>/g, "").trim(), rules, { replacement: escapeHtmlText });
|
|
238
38
|
}
|
|
239
39
|
function sanitizeArtifactName(input) {
|
|
240
40
|
return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "artifact";
|
|
@@ -247,6 +47,9 @@ function shortHash(input) {
|
|
|
247
47
|
}
|
|
248
48
|
return hash.toString(16).padStart(8, "0");
|
|
249
49
|
}
|
|
50
|
+
function escapeHtmlText(input) {
|
|
51
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
52
|
+
}
|
|
250
53
|
//#endregion
|
|
251
54
|
//#region src/agent/schema.ts
|
|
252
55
|
const agentTextMaskRuleSchema = z.object({
|
|
@@ -394,73 +197,15 @@ function resolveAgentLaunchMode(launch) {
|
|
|
394
197
|
return inferAgentLaunchMode(launch);
|
|
395
198
|
}
|
|
396
199
|
//#endregion
|
|
397
|
-
//#region src/agent/
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
viewport: agentViewportSchema,
|
|
401
|
-
phase: z.number().int().nonnegative(),
|
|
402
|
-
stepIndex: z.number().int().nonnegative().nullable(),
|
|
403
|
-
stepType: z.string().min(1),
|
|
404
|
-
terminalText: z.string(),
|
|
405
|
-
terminalHash: z.string().min(1),
|
|
406
|
-
dom: z.string(),
|
|
407
|
-
domHash: z.string().min(1),
|
|
408
|
-
capturedAt: z.string().min(1)
|
|
409
|
-
});
|
|
410
|
-
const agentCassetteSchema = z.object({
|
|
411
|
-
$schema: z.string().optional(),
|
|
412
|
-
version: z.literal(1),
|
|
413
|
-
name: z.string().min(1),
|
|
414
|
-
createdAt: z.string().min(1),
|
|
415
|
-
spec: agentFlowSpecSchema.optional(),
|
|
416
|
-
frames: z.array(agentCassetteFrameSchema).min(1)
|
|
417
|
-
});
|
|
418
|
-
function createAgentCassette(name, spec) {
|
|
419
|
-
return {
|
|
420
|
-
$schema: AGENT_CASSETTE_SCHEMA_URL,
|
|
421
|
-
version: 1,
|
|
422
|
-
name,
|
|
423
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
424
|
-
spec: normalizeAgentFlowSpec(spec),
|
|
425
|
-
frames: []
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
function normalizeAgentCassette(input, fallbackSpec) {
|
|
429
|
-
const parsed = agentCassetteSchema.parse(input);
|
|
430
|
-
const specInput = parsed.spec ?? fallbackSpec;
|
|
431
|
-
if (!specInput) throw new Error("invalid agent cassette: missing spec");
|
|
432
|
-
validateCassetteFrameHashes(parsed.frames);
|
|
433
|
-
return {
|
|
434
|
-
...parsed,
|
|
435
|
-
$schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json",
|
|
436
|
-
spec: normalizeAgentFlowSpec(specInput)
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
function validateCassetteFrameHashes(frames) {
|
|
440
|
-
for (const frame of frames) {
|
|
441
|
-
if (shortHash(frame.terminalText) !== frame.terminalHash) throw new Error(`invalid agent cassette: terminal hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
|
|
442
|
-
if (shortHash(frame.dom) !== frame.domHash) throw new Error(`invalid agent cassette: dom hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
function readAgentCassettePath(path, fallbackSpec) {
|
|
446
|
-
return normalizeAgentCassette(JSON.parse(readFileSync(path, "utf8")), fallbackSpec);
|
|
447
|
-
}
|
|
448
|
-
function isAgentCassetteLike(input) {
|
|
449
|
-
return typeof input === "object" && input !== null && input.version === 1 && Array.isArray(input.frames);
|
|
200
|
+
//#region src/agent/html_escape.ts
|
|
201
|
+
function escapeHtml(input) {
|
|
202
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
450
203
|
}
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
...frame,
|
|
454
|
-
terminalHash: shortHash(frame.terminalText),
|
|
455
|
-
domHash: shortHash(frame.dom)
|
|
456
|
-
};
|
|
457
|
-
const index = cassette.frames.findIndex((candidate) => candidate.viewport.name === next.viewport.name && candidate.phase === next.phase);
|
|
458
|
-
if (index >= 0) {
|
|
459
|
-
cassette.frames[index] = next;
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
cassette.frames.push(next);
|
|
204
|
+
function escapeAttribute(input) {
|
|
205
|
+
return escapeHtml(input).replace(/'/g, "'");
|
|
463
206
|
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/agent/cassette_server.ts
|
|
464
209
|
async function startAgentCassetteServer(cassette) {
|
|
465
210
|
const sockets = /* @__PURE__ */ new Set();
|
|
466
211
|
const server = createServer((request, response) => {
|
|
@@ -496,7 +241,7 @@ async function startAgentCassetteServer(cassette) {
|
|
|
496
241
|
});
|
|
497
242
|
const address = server.address();
|
|
498
243
|
if (!address || typeof address === "string") {
|
|
499
|
-
await closeServer(server);
|
|
244
|
+
await closeServer(server, sockets);
|
|
500
245
|
throw new Error("failed to bind cassette replay server");
|
|
501
246
|
}
|
|
502
247
|
return {
|
|
@@ -511,7 +256,7 @@ function renderCassetteHtml(cassette) {
|
|
|
511
256
|
<head>
|
|
512
257
|
<meta charset="utf-8" />
|
|
513
258
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
514
|
-
<title>${escapeHtml
|
|
259
|
+
<title>${escapeHtml(cassette.name)} cassette replay</title>
|
|
515
260
|
<style>
|
|
516
261
|
:root {
|
|
517
262
|
color-scheme: dark;
|
|
@@ -542,10 +287,25 @@ function renderCassetteHtml(cassette) {
|
|
|
542
287
|
const root = document.querySelector("[data-terminal-root]");
|
|
543
288
|
let phase = 0;
|
|
544
289
|
|
|
290
|
+
function queryViewport() {
|
|
291
|
+
const params = new URLSearchParams(window.location.search);
|
|
292
|
+
const width = Number.parseInt(params.get("viewportWidth") || "", 10);
|
|
293
|
+
const height = Number.parseInt(params.get("viewportHeight") || "", 10);
|
|
294
|
+
const name = params.get("viewportName") || "";
|
|
295
|
+
return {
|
|
296
|
+
height: Number.isFinite(height) && height > 0 ? height : window.innerHeight,
|
|
297
|
+
name,
|
|
298
|
+
width: Number.isFinite(width) && width > 0 ? width : window.innerWidth,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
545
302
|
function viewportScore(frame) {
|
|
546
303
|
const viewport = frame.viewport || {};
|
|
547
|
-
|
|
548
|
-
|
|
304
|
+
const target = queryViewport();
|
|
305
|
+
const namePenalty = target.name && viewport.name !== target.name ? 100000 : 0;
|
|
306
|
+
return namePenalty +
|
|
307
|
+
Math.abs((viewport.width || target.width) - target.width) +
|
|
308
|
+
Math.abs((viewport.height || target.height) - target.height);
|
|
549
309
|
}
|
|
550
310
|
|
|
551
311
|
function chooseFrame() {
|
|
@@ -620,288 +380,193 @@ async function closeServer(server, sockets) {
|
|
|
620
380
|
});
|
|
621
381
|
});
|
|
622
382
|
}
|
|
623
|
-
function escapeHtml$1(input) {
|
|
624
|
-
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
625
|
-
}
|
|
626
383
|
//#endregion
|
|
627
|
-
//#region src/agent/
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
function resolveNamedDir(root, name, configRoot) {
|
|
652
|
-
if (!root) return void 0;
|
|
653
|
-
const namedDir = join(root, name);
|
|
654
|
-
return isAbsolute(namedDir) ? namedDir : resolve(configRoot, namedDir);
|
|
655
|
-
}
|
|
656
|
-
function cloneViewports(viewports) {
|
|
657
|
-
return Array.isArray(viewports) && viewports.length > 0 ? viewports.map((viewport) => ({ ...viewport })) : void 0;
|
|
658
|
-
}
|
|
659
|
-
function mergeMaskRules(configMask, specMask) {
|
|
660
|
-
const merged = [...configMask ?? [], ...specMask ?? []];
|
|
661
|
-
return merged.length > 0 ? merged : void 0;
|
|
662
|
-
}
|
|
663
|
-
//#endregion
|
|
664
|
-
//#region src/agent/command_launch.ts
|
|
665
|
-
const DEFAULT_URL_REGEX = /https?:\/\/[^\s"'<>]+/;
|
|
666
|
-
function buildCommandLaunchCommand(launch, options = {}) {
|
|
667
|
-
if (!launch.command) throw new Error("launch.command is required when launch.mode is 'command'");
|
|
668
|
-
const rootDir = options.rootDir ?? process.cwd();
|
|
669
|
-
const cwd = launch.cwd ? resolve(rootDir, launch.cwd) : rootDir;
|
|
384
|
+
//#region src/agent/cassette.ts
|
|
385
|
+
const AGENT_CASSETTE_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json";
|
|
386
|
+
const agentCassetteFrameSchema = z.object({
|
|
387
|
+
viewport: agentViewportSchema,
|
|
388
|
+
phase: z.number().int().nonnegative(),
|
|
389
|
+
stepIndex: z.number().int().nonnegative().nullable(),
|
|
390
|
+
stepType: z.string().min(1),
|
|
391
|
+
terminalText: z.string(),
|
|
392
|
+
terminalHash: z.string().min(1),
|
|
393
|
+
dom: z.string(),
|
|
394
|
+
domHash: z.string().min(1),
|
|
395
|
+
capturedAt: z.string().min(1)
|
|
396
|
+
});
|
|
397
|
+
const agentCassetteSchema = z.object({
|
|
398
|
+
$schema: z.string().optional(),
|
|
399
|
+
version: z.literal(1),
|
|
400
|
+
name: z.string().min(1),
|
|
401
|
+
createdAt: z.string().min(1),
|
|
402
|
+
spec: agentFlowSpecSchema.optional(),
|
|
403
|
+
frames: z.array(agentCassetteFrameSchema).min(1)
|
|
404
|
+
});
|
|
405
|
+
function createAgentCassette(name, spec) {
|
|
670
406
|
return {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
},
|
|
678
|
-
label: launch.command,
|
|
679
|
-
urlRegex: launch.urlRegex,
|
|
680
|
-
waitForUrlMs: launch.waitForUrlMs
|
|
407
|
+
$schema: AGENT_CASSETTE_SCHEMA_URL,
|
|
408
|
+
version: 1,
|
|
409
|
+
name,
|
|
410
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
411
|
+
spec: normalizeAgentFlowSpec(spec),
|
|
412
|
+
frames: []
|
|
681
413
|
};
|
|
682
414
|
}
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
stdio: [
|
|
689
|
-
"ignore",
|
|
690
|
-
"pipe",
|
|
691
|
-
"pipe"
|
|
692
|
-
]
|
|
693
|
-
});
|
|
694
|
-
const stdoutChunks = [];
|
|
695
|
-
const stderrChunks = [];
|
|
415
|
+
function normalizeAgentCassette(input, fallbackSpec) {
|
|
416
|
+
const parsed = agentCassetteSchema.parse(input);
|
|
417
|
+
const specInput = parsed.spec ?? fallbackSpec;
|
|
418
|
+
if (!specInput) throw new Error("invalid agent cassette: missing spec");
|
|
419
|
+
validateCassetteFrameHashes(parsed.frames);
|
|
696
420
|
return {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
finish(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for ${command.label ?? command.file} session URL\nstdout=${stdoutChunks.join("").trim()}\nstderr=${stderrChunks.join("").trim()}`));
|
|
701
|
-
}, timeoutMs);
|
|
702
|
-
const finish = (result) => {
|
|
703
|
-
if (settled) return;
|
|
704
|
-
settled = true;
|
|
705
|
-
clearTimeout(timer);
|
|
706
|
-
child.stdout.off("data", onStdout);
|
|
707
|
-
child.stderr.off("data", onStderr);
|
|
708
|
-
child.off("error", onError);
|
|
709
|
-
child.off("exit", onExit);
|
|
710
|
-
if (result instanceof Error) reject(result);
|
|
711
|
-
else resolveUrl(result);
|
|
712
|
-
};
|
|
713
|
-
const readUrl = () => {
|
|
714
|
-
const found = extractUrlFromOutput(`${stdoutChunks.join("")}\n${stderrChunks.join("")}`, command.urlRegex);
|
|
715
|
-
if (found) finish(found);
|
|
716
|
-
};
|
|
717
|
-
const onStdout = (chunk) => {
|
|
718
|
-
stdoutChunks.push(chunk.toString("utf8"));
|
|
719
|
-
readUrl();
|
|
720
|
-
};
|
|
721
|
-
const onStderr = (chunk) => {
|
|
722
|
-
stderrChunks.push(chunk.toString("utf8"));
|
|
723
|
-
readUrl();
|
|
724
|
-
};
|
|
725
|
-
const onError = (error) => finish(error);
|
|
726
|
-
const onExit = (code, signal) => {
|
|
727
|
-
finish(/* @__PURE__ */ new Error(`${command.label ?? command.file} exited before printing a session URL (code=${code ?? "null"} signal=${signal ?? "null"})\nstdout=${stdoutChunks.join("").trim()}\nstderr=${stderrChunks.join("").trim()}`));
|
|
728
|
-
};
|
|
729
|
-
child.stdout.on("data", onStdout);
|
|
730
|
-
child.stderr.on("data", onStderr);
|
|
731
|
-
child.once("error", onError);
|
|
732
|
-
child.once("exit", onExit);
|
|
733
|
-
}),
|
|
734
|
-
process: child,
|
|
735
|
-
close: () => closeChild(child)
|
|
421
|
+
...parsed,
|
|
422
|
+
$schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json",
|
|
423
|
+
spec: normalizeAgentFlowSpec(specInput)
|
|
736
424
|
};
|
|
737
425
|
}
|
|
738
|
-
function
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
function formatBrowserLaunchCommand(command) {
|
|
744
|
-
return [command.file, ...command.args].join(" ");
|
|
426
|
+
function validateCassetteFrameHashes(frames) {
|
|
427
|
+
for (const frame of frames) {
|
|
428
|
+
if (shortHash(frame.terminalText) !== frame.terminalHash) throw new Error(`invalid agent cassette: terminal hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
|
|
429
|
+
if (shortHash(frame.dom) !== frame.domHash) throw new Error(`invalid agent cassette: dom hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
|
|
430
|
+
}
|
|
745
431
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
await new Promise((resolveClose) => {
|
|
749
|
-
const timer = setTimeout(() => {
|
|
750
|
-
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
751
|
-
resolveClose();
|
|
752
|
-
}, 2e3);
|
|
753
|
-
child.once("exit", () => {
|
|
754
|
-
clearTimeout(timer);
|
|
755
|
-
resolveClose();
|
|
756
|
-
});
|
|
757
|
-
child.kill("SIGTERM");
|
|
758
|
-
});
|
|
432
|
+
function readAgentCassettePath(path, fallbackSpec) {
|
|
433
|
+
return normalizeAgentCassette(JSON.parse(readFileSync(path, "utf8")), fallbackSpec);
|
|
759
434
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
function buildAgentLaunchCommand(launch, options = {}) {
|
|
763
|
-
if (resolveAgentLaunchMode(launch) === "url") return null;
|
|
764
|
-
return buildCommandLaunchCommand(launch, options);
|
|
435
|
+
function isAgentCassetteLike(input) {
|
|
436
|
+
return typeof input === "object" && input !== null && input.version === 1 && Array.isArray(input.frames);
|
|
765
437
|
}
|
|
766
|
-
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
session: null
|
|
772
|
-
};
|
|
773
|
-
const session = await launchBrowserSessionFromCommand(buildCommandLaunchCommand(launch, options));
|
|
774
|
-
return {
|
|
775
|
-
mode,
|
|
776
|
-
url: session.url,
|
|
777
|
-
session
|
|
438
|
+
function upsertAgentCassetteFrame(cassette, frame) {
|
|
439
|
+
const next = {
|
|
440
|
+
...frame,
|
|
441
|
+
terminalHash: shortHash(frame.terminalText),
|
|
442
|
+
domHash: shortHash(frame.dom)
|
|
778
443
|
};
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
444
|
+
const index = cassette.frames.findIndex((candidate) => candidate.viewport.name === next.viewport.name && candidate.phase === next.phase);
|
|
445
|
+
if (index >= 0) {
|
|
446
|
+
cassette.frames[index] = next;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
cassette.frames.push(next);
|
|
783
450
|
}
|
|
784
451
|
//#endregion
|
|
785
|
-
//#region src/agent/
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
if (command === "claude" || command === "claude-code" || command.startsWith("claude-")) return "claude";
|
|
858
|
-
if (command === "droid" || command === "droidx" || command.startsWith("droid")) return "droid";
|
|
859
|
-
return "generic";
|
|
452
|
+
//#region src/agent/manifest.ts
|
|
453
|
+
const AGENT_MANIFEST_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json";
|
|
454
|
+
const AGENT_MANIFEST_FILE_NAME = "ptywright-agent.manifest.json";
|
|
455
|
+
const agentManifestKindSchema = z.enum([
|
|
456
|
+
"run",
|
|
457
|
+
"replay-suite",
|
|
458
|
+
"check",
|
|
459
|
+
"promote"
|
|
460
|
+
]);
|
|
461
|
+
const agentManifestFileKindSchema = z.enum([
|
|
462
|
+
"flow",
|
|
463
|
+
"cassette",
|
|
464
|
+
"run-record",
|
|
465
|
+
"replay-summary",
|
|
466
|
+
"check-summary",
|
|
467
|
+
"promote-summary",
|
|
468
|
+
"report",
|
|
469
|
+
"terminal",
|
|
470
|
+
"dom",
|
|
471
|
+
"screenshot",
|
|
472
|
+
"diff"
|
|
473
|
+
]);
|
|
474
|
+
const agentManifestCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
|
|
475
|
+
const agentManifestValidationStageSchema = z.object({
|
|
476
|
+
name: z.string().min(1),
|
|
477
|
+
ok: z.boolean(),
|
|
478
|
+
totalCount: z.number().int().nonnegative(),
|
|
479
|
+
failureCount: z.number().int().nonnegative()
|
|
480
|
+
}).strict();
|
|
481
|
+
const agentManifestValidationSchema = z.object({
|
|
482
|
+
ok: z.boolean(),
|
|
483
|
+
stages: z.array(agentManifestValidationStageSchema)
|
|
484
|
+
}).strict().superRefine((validation, ctx) => {
|
|
485
|
+
const ok = validation.stages.every((stage) => stage.ok && stage.failureCount === 0);
|
|
486
|
+
if (validation.ok !== ok) ctx.addIssue({
|
|
487
|
+
code: z.ZodIssueCode.custom,
|
|
488
|
+
path: ["ok"],
|
|
489
|
+
message: "validation ok must match validation stages"
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
const agentManifestFileSchema = z.object({
|
|
493
|
+
path: z.string().min(1),
|
|
494
|
+
kind: agentManifestFileKindSchema,
|
|
495
|
+
role: z.string().min(1).optional(),
|
|
496
|
+
ok: z.boolean().optional(),
|
|
497
|
+
bytes: z.number().int().nonnegative(),
|
|
498
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/)
|
|
499
|
+
}).strict();
|
|
500
|
+
const agentManifestSchema = z.object({
|
|
501
|
+
$schema: z.string().optional(),
|
|
502
|
+
version: z.literal(1),
|
|
503
|
+
kind: agentManifestKindSchema,
|
|
504
|
+
ok: z.boolean(),
|
|
505
|
+
generatedAt: z.string().min(1),
|
|
506
|
+
rootDir: z.string().min(1),
|
|
507
|
+
primaryPath: z.string().min(1),
|
|
508
|
+
commands: z.record(agentManifestCommandSchema),
|
|
509
|
+
validation: agentManifestValidationSchema.optional(),
|
|
510
|
+
files: z.array(agentManifestFileSchema)
|
|
511
|
+
}).strict().superRefine((manifest, ctx) => {
|
|
512
|
+
const seen = /* @__PURE__ */ new Set();
|
|
513
|
+
for (const file of manifest.files) {
|
|
514
|
+
if (seen.has(file.path)) ctx.addIssue({
|
|
515
|
+
code: z.ZodIssueCode.custom,
|
|
516
|
+
path: ["files"],
|
|
517
|
+
message: `duplicate manifest file path: ${file.path}`
|
|
518
|
+
});
|
|
519
|
+
seen.add(file.path);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
function agentManifestPath(rootDir) {
|
|
523
|
+
return join(rootDir, AGENT_MANIFEST_FILE_NAME);
|
|
860
524
|
}
|
|
861
|
-
function
|
|
862
|
-
return
|
|
525
|
+
function createAgentManifest(options) {
|
|
526
|
+
return normalizeAgentManifest({
|
|
527
|
+
$schema: AGENT_MANIFEST_SCHEMA_URL,
|
|
528
|
+
version: 1,
|
|
529
|
+
kind: options.kind,
|
|
530
|
+
ok: options.ok,
|
|
531
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
532
|
+
rootDir: options.rootDir,
|
|
533
|
+
primaryPath: options.primaryPath,
|
|
534
|
+
commands: options.commands,
|
|
535
|
+
validation: options.validation,
|
|
536
|
+
files: collectManifestFiles(options.files, options.rootDir)
|
|
537
|
+
});
|
|
863
538
|
}
|
|
864
|
-
function
|
|
865
|
-
return
|
|
539
|
+
function readAgentManifestPath(path) {
|
|
540
|
+
return normalizeAgentManifest(JSON.parse(readFileSync(path, "utf8")));
|
|
866
541
|
}
|
|
867
|
-
function
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
}, {
|
|
896
|
-
type: "snapshot",
|
|
897
|
-
name: "launch",
|
|
898
|
-
targets: [
|
|
899
|
-
"terminal",
|
|
900
|
-
"dom",
|
|
901
|
-
"screenshot"
|
|
902
|
-
]
|
|
903
|
-
}]
|
|
904
|
-
};
|
|
542
|
+
function writeAgentManifestPath(path, options) {
|
|
543
|
+
const manifest = createAgentManifest(options);
|
|
544
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
545
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
546
|
+
return manifest;
|
|
547
|
+
}
|
|
548
|
+
function normalizeAgentManifest(input) {
|
|
549
|
+
try {
|
|
550
|
+
const parsed = agentManifestSchema.parse(input);
|
|
551
|
+
return {
|
|
552
|
+
...parsed,
|
|
553
|
+
$schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json"
|
|
554
|
+
};
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error instanceof z.ZodError) throw new Error(`invalid agent manifest: ${formatZodIssues(error)}`);
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function validateAgentManifestFiles(manifest, manifestPath) {
|
|
561
|
+
validateManifestFiles({
|
|
562
|
+
files: manifest.files,
|
|
563
|
+
manifestPath,
|
|
564
|
+
rootDir: manifest.rootDir,
|
|
565
|
+
label: "agent"
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function isAgentManifestLike(input) {
|
|
569
|
+
return typeof input === "object" && input !== null && input.version === 1 && typeof input.kind === "string" && Array.isArray(input.files) && typeof input.commands === "object";
|
|
905
570
|
}
|
|
906
571
|
//#endregion
|
|
907
572
|
//#region src/common/argv.ts
|
|
@@ -1011,9 +676,6 @@ function normalizeAgentRunRecord(input) {
|
|
|
1011
676
|
function formatAgentArgv(argv) {
|
|
1012
677
|
return formatArgv(argv);
|
|
1013
678
|
}
|
|
1014
|
-
function sameArgv(left, right) {
|
|
1015
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1016
|
-
}
|
|
1017
679
|
function isReplayArgv(argv) {
|
|
1018
680
|
return argv.length >= 4 && argv[0] === "ptywright" && argv[1] === "agent" && argv[2] === "replay";
|
|
1019
681
|
}
|
|
@@ -1032,175 +694,1609 @@ function isAgentRunRecordLike(input) {
|
|
|
1032
694
|
const candidate = input;
|
|
1033
695
|
return candidate.version === 1 && ("cassettePath" in candidate || "flowPath" in candidate || "spec" in candidate);
|
|
1034
696
|
}
|
|
1035
|
-
function formatZodIssues(error) {
|
|
1036
|
-
return error.issues.map((issue) => {
|
|
1037
|
-
return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
1038
|
-
}).join("; ");
|
|
1039
|
-
}
|
|
1040
697
|
//#endregion
|
|
1041
|
-
//#region src/agent/
|
|
1042
|
-
function
|
|
1043
|
-
|
|
1044
|
-
|
|
698
|
+
//#region src/agent/spec_loader.ts
|
|
699
|
+
async function loadAgentSpec(specPath) {
|
|
700
|
+
const resolved = resolve(process.cwd(), specPath);
|
|
701
|
+
if (resolved.endsWith(".json")) {
|
|
702
|
+
const raw = JSON.parse(readFileSync(resolved, "utf8"));
|
|
703
|
+
return {
|
|
704
|
+
spec: normalizeAgentFlowSpec(raw),
|
|
705
|
+
raw,
|
|
706
|
+
path: resolved
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const mod = await import(`${pathToFileURL(resolved).href}?t=${Date.now()}`);
|
|
710
|
+
const raw = mod.default ?? mod.spec;
|
|
711
|
+
return {
|
|
712
|
+
spec: normalizeAgentFlowSpec(raw),
|
|
713
|
+
raw,
|
|
714
|
+
path: resolved
|
|
715
|
+
};
|
|
1045
716
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/agent/presets.ts
|
|
719
|
+
const COMMON_AGENT_MASKS = [
|
|
720
|
+
{
|
|
721
|
+
regex: "\\b\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?\\b",
|
|
722
|
+
replacement: "<timestamp>"
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
regex: "\\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\b",
|
|
726
|
+
flags: "gi",
|
|
727
|
+
replacement: "<uuid>"
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
regex: "\\b(?:req|run|msg|chatcmpl|call|toolu|session)_[A-Za-z0-9_-]{8,}\\b",
|
|
731
|
+
replacement: "<id>"
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
regex: "\\b(?:[0-9a-f]{7,40})\\b",
|
|
735
|
+
flags: "gi",
|
|
736
|
+
replacement: "<hex>"
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
regex: "\\$\\d+(?:\\.\\d{2,6})?\\b",
|
|
740
|
+
replacement: "$<amount>"
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
regex: "\\b(?:\\d+\\.\\d+|\\d+)\\s*(?:s|ms|tokens?|tok)\\b",
|
|
744
|
+
flags: "gi",
|
|
745
|
+
replacement: "<metric>"
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
const FLAVOR_MASKS = {
|
|
749
|
+
codex: [{
|
|
750
|
+
regex: "\\b(?:gpt-[A-Za-z0-9._:-]+|o[0-9][A-Za-z0-9._:-]*)\\b",
|
|
751
|
+
flags: "gi",
|
|
752
|
+
replacement: "<model>"
|
|
753
|
+
}, {
|
|
754
|
+
regex: "\\b(?:context|tokens?)\\s*[:=]\\s*[0-9,]+\\b",
|
|
755
|
+
flags: "gi",
|
|
756
|
+
replacement: "<token-count>"
|
|
757
|
+
}],
|
|
758
|
+
claude: [{
|
|
759
|
+
regex: "\\bclaude-[A-Za-z0-9._-]+\\b",
|
|
760
|
+
flags: "gi",
|
|
761
|
+
replacement: "<model>"
|
|
762
|
+
}, {
|
|
763
|
+
regex: "\\b(?:Opus|Sonnet|Haiku)\\s+[0-9.]+\\b",
|
|
764
|
+
flags: "gi",
|
|
765
|
+
replacement: "<model>"
|
|
766
|
+
}],
|
|
767
|
+
droid: [{
|
|
768
|
+
regex: "\\bdroidx?-[A-Za-z0-9._-]+\\b",
|
|
769
|
+
flags: "gi",
|
|
770
|
+
replacement: "<droid-id>"
|
|
771
|
+
}],
|
|
772
|
+
generic: []
|
|
773
|
+
};
|
|
774
|
+
const DEFAULT_VIEWPORTS = [{
|
|
775
|
+
name: "desktop",
|
|
776
|
+
width: 1280,
|
|
777
|
+
height: 820
|
|
778
|
+
}, {
|
|
779
|
+
name: "mobile",
|
|
780
|
+
width: 390,
|
|
781
|
+
height: 844,
|
|
782
|
+
isMobile: true,
|
|
783
|
+
hasTouch: true
|
|
784
|
+
}];
|
|
785
|
+
function resolveAgentFlavor(spec) {
|
|
786
|
+
const explicit = spec.launch.agentFlavor;
|
|
787
|
+
if (explicit) return explicit;
|
|
788
|
+
const command = spec.launch.command?.split(/[\\/]/).at(-1)?.toLowerCase() ?? "";
|
|
789
|
+
if (command === "codex" || command.startsWith("codex-")) return "codex";
|
|
790
|
+
if (command === "claude" || command === "claude-code" || command.startsWith("claude-")) return "claude";
|
|
791
|
+
if (command === "droid" || command === "droidx" || command.startsWith("droid")) return "droid";
|
|
792
|
+
return "generic";
|
|
793
|
+
}
|
|
794
|
+
function getAgentMaskPreset(flavor) {
|
|
795
|
+
return [...COMMON_AGENT_MASKS, ...FLAVOR_MASKS[flavor]];
|
|
796
|
+
}
|
|
797
|
+
function resolveAgentMasks(spec) {
|
|
798
|
+
return [...getAgentMaskPreset(resolveAgentFlavor(spec)), ...spec.defaults?.mask ?? []];
|
|
799
|
+
}
|
|
800
|
+
function createAgentTemplateSpec(flavor) {
|
|
801
|
+
const command = flavor === "droid" ? "droidx" : flavor === "generic" ? "agent" : flavor;
|
|
802
|
+
const name = flavor === "generic" ? "agent_browser_smoke" : `${flavor}_browser_smoke`;
|
|
803
|
+
return {
|
|
804
|
+
name,
|
|
805
|
+
artifactsDir: `.tmp/agent/${name}`,
|
|
806
|
+
snapshotDir: `tests/agent-snapshots/${name}`,
|
|
807
|
+
launch: {
|
|
808
|
+
mode: "command",
|
|
809
|
+
agentFlavor: flavor,
|
|
810
|
+
command: "your-browser-terminal-launcher",
|
|
811
|
+
args: [
|
|
812
|
+
"--agent",
|
|
813
|
+
command,
|
|
814
|
+
"--print-url"
|
|
815
|
+
],
|
|
816
|
+
waitForUrlMs: 15e3
|
|
817
|
+
},
|
|
818
|
+
viewports: DEFAULT_VIEWPORTS.map((viewport) => ({ ...viewport })),
|
|
819
|
+
defaults: {
|
|
820
|
+
timeoutMs: 45e3,
|
|
821
|
+
screenshot: true
|
|
822
|
+
},
|
|
823
|
+
steps: [{
|
|
824
|
+
type: "waitForStableDom",
|
|
825
|
+
timeoutMs: 45e3,
|
|
826
|
+
quietMs: 600,
|
|
827
|
+
intervalMs: 150
|
|
828
|
+
}, {
|
|
829
|
+
type: "snapshot",
|
|
830
|
+
name: "launch",
|
|
831
|
+
targets: [
|
|
832
|
+
"terminal",
|
|
833
|
+
"dom",
|
|
834
|
+
"screenshot"
|
|
835
|
+
]
|
|
836
|
+
}]
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
//#endregion
|
|
840
|
+
//#region src/agent/browser.ts
|
|
841
|
+
async function launchAgentBrowser(args) {
|
|
842
|
+
let lastError;
|
|
843
|
+
for (let attempt = 0; attempt < 5; attempt += 1) try {
|
|
844
|
+
const browser = await chromium.launch({ headless: args.headless });
|
|
845
|
+
await verifyBrowserLaunch(browser);
|
|
846
|
+
return browser;
|
|
847
|
+
} catch (error) {
|
|
848
|
+
lastError = error;
|
|
849
|
+
if (!isTransientBrowserLaunchError(error) || attempt === 4) throw error;
|
|
850
|
+
await new Promise((resolveRetry) => setTimeout(resolveRetry, 250 * (attempt + 1)));
|
|
851
|
+
}
|
|
852
|
+
throw lastError;
|
|
853
|
+
}
|
|
854
|
+
async function verifyBrowserLaunch(browser) {
|
|
855
|
+
const context = await browser.newContext();
|
|
856
|
+
try {
|
|
857
|
+
const page = await context.newPage();
|
|
858
|
+
await page.goto("data:text/html,<title>ptywright</title>", { waitUntil: "load" });
|
|
859
|
+
await page.close();
|
|
860
|
+
} catch (error) {
|
|
861
|
+
await browser.close();
|
|
862
|
+
throw error;
|
|
863
|
+
} finally {
|
|
864
|
+
await context.close().catch(() => void 0);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function isTransientBrowserLaunchError(error) {
|
|
868
|
+
const fields = errorFields(error);
|
|
869
|
+
return fields.message.includes("Failed to connect") || fields.message.includes("Target page, context or browser has been closed") || fields.message.includes("browserType.launch") || fields.code === "ENOENT" || fields.code === "ECONNREFUSED" || fields.syscall === "connect";
|
|
870
|
+
}
|
|
871
|
+
function errorFields(error) {
|
|
872
|
+
const record = typeof error === "object" && error !== null ? error : {};
|
|
873
|
+
return {
|
|
874
|
+
message: error instanceof Error ? error.message : String(error),
|
|
875
|
+
code: typeof record.code === "string" ? record.code : "",
|
|
876
|
+
syscall: typeof record.syscall === "string" ? record.syscall : ""
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
//#region src/agent/aitty_report_assets.ts
|
|
881
|
+
function prepareAittyReportAssets(context) {
|
|
882
|
+
const sources = resolveAittyReportAssetSources(context);
|
|
883
|
+
if (!existsSync(sources.scriptSource) || !existsSync(sources.styleSource)) throw new Error("@aitty/snapshot report assets are missing. Run the package build before generating reports.");
|
|
884
|
+
const assetDir = join(context.artifactsDir, "assets");
|
|
885
|
+
const scriptPath = join(assetDir, "aitty-web-component.js");
|
|
886
|
+
const stylePath = join(assetDir, "aitty-terminal.css");
|
|
887
|
+
mkdirSync(assetDir, { recursive: true });
|
|
888
|
+
copyFileSync(sources.scriptSource, scriptPath);
|
|
889
|
+
copyFileSync(sources.styleSource, stylePath);
|
|
890
|
+
return {
|
|
891
|
+
scriptPath,
|
|
892
|
+
scriptType: sources.scriptType,
|
|
893
|
+
stylePath
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function resolveAittyReportAssetSources(context) {
|
|
897
|
+
for (const resolverBase of resolveAittyReportResolverBases(context)) {
|
|
898
|
+
const sources = tryResolveAittyReportAssetSources(createRequire(resolverBase));
|
|
899
|
+
if (sources) return sources;
|
|
900
|
+
}
|
|
901
|
+
const fallback = tryResolveAittyReportAssetSources(createRequire(import.meta.url));
|
|
902
|
+
if (fallback) return fallback;
|
|
903
|
+
throw new Error("@aitty/snapshot report assets are missing. Install @aitty/snapshot or run the package build before generating reports.");
|
|
904
|
+
}
|
|
905
|
+
function resolveAittyReportResolverBases(context) {
|
|
906
|
+
const candidates = [
|
|
907
|
+
findNearestPackageJson(dirname(resolve(context.flowPath))),
|
|
908
|
+
findNearestPackageJson(dirname(resolve(context.reportPath))),
|
|
909
|
+
findNearestPackageJson(dirname(resolve(context.artifactsDir))),
|
|
910
|
+
findNearestPackageJson(process.cwd())
|
|
911
|
+
].filter((path) => Boolean(path));
|
|
912
|
+
return Array.from(new Set(candidates));
|
|
913
|
+
}
|
|
914
|
+
function findNearestPackageJson(startDir) {
|
|
915
|
+
let currentDir = resolve(startDir);
|
|
916
|
+
while (true) {
|
|
917
|
+
const packagePath = join(currentDir, "package.json");
|
|
918
|
+
if (existsSync(packagePath)) return packagePath;
|
|
919
|
+
const parentDir = dirname(currentDir);
|
|
920
|
+
if (parentDir === currentDir) return null;
|
|
921
|
+
currentDir = parentDir;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function tryResolveAittyReportAssetSources(resolver) {
|
|
925
|
+
return tryResolveAittyPackageAssetSources(resolver, "@aitty/snapshot") ?? tryResolveAittyPackageAssetSources(resolver, "@aitty/browser");
|
|
926
|
+
}
|
|
927
|
+
function tryResolveAittyPackageAssetSources(resolver, packageName) {
|
|
928
|
+
let scriptSource;
|
|
929
|
+
let scriptType = "classic";
|
|
930
|
+
let styleSource;
|
|
931
|
+
try {
|
|
932
|
+
scriptSource = resolver.resolve(`${packageName}/web-component.global.js`);
|
|
933
|
+
} catch {
|
|
934
|
+
try {
|
|
935
|
+
scriptSource = resolver.resolve(`${packageName}/web-component.js`);
|
|
936
|
+
scriptType = "module";
|
|
937
|
+
} catch {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
styleSource = resolver.resolve(`${packageName}/style.css`);
|
|
943
|
+
} catch {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
scriptSource,
|
|
948
|
+
scriptType,
|
|
949
|
+
styleSource
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
//#endregion
|
|
953
|
+
//#region src/agent/report_paths.ts
|
|
954
|
+
function relativeHref(fromPath, targetPath, artifactsDir) {
|
|
955
|
+
if (targetPath.startsWith(artifactsDir)) return relative(dirname(fromPath), targetPath).replaceAll("\\", "/") || ".";
|
|
956
|
+
return targetPath;
|
|
957
|
+
}
|
|
958
|
+
//#endregion
|
|
959
|
+
//#region src/agent/report_aitty_preview.ts
|
|
960
|
+
function resolveAittyPreviewAssets(previewPath, assets, artifactsDir) {
|
|
961
|
+
return {
|
|
962
|
+
scriptHref: relativeHref(previewPath, assets.scriptPath, artifactsDir),
|
|
963
|
+
scriptType: assets.scriptType,
|
|
964
|
+
styleHref: relativeHref(previewPath, assets.stylePath, artifactsDir)
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function renderAittyPreviewAssetTags(assets) {
|
|
968
|
+
return ` <link rel="stylesheet" href="${escapeAttribute(assets.styleHref)}" />
|
|
969
|
+
<script${assets.scriptType === "module" ? " type=\"module\"" : ""} src="${escapeAttribute(assets.scriptHref)}"><\/script>
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
function renderAittyPreviewBody(args) {
|
|
973
|
+
const { snapshot, snapshotLayout, viewOptions } = args;
|
|
974
|
+
return ` <aitty-snapshot
|
|
975
|
+
cols="${snapshotLayout.cols}"
|
|
976
|
+
rows="${snapshotLayout.rows}"
|
|
977
|
+
screen-mode="${escapeAttribute(viewOptions.screenMode)}"
|
|
978
|
+
theme="${escapeAttribute(viewOptions.theme)}"
|
|
979
|
+
font-size="${snapshotLayout.fontSize}"
|
|
980
|
+
line-height="${snapshotLayout.lineHeight}"
|
|
981
|
+
>${snapshot}</aitty-snapshot>`;
|
|
982
|
+
}
|
|
983
|
+
function renderAittyPreviewCss(viewOptions) {
|
|
984
|
+
return ` :root {
|
|
985
|
+
color-scheme: ${viewOptions.theme};
|
|
986
|
+
}
|
|
987
|
+
html,
|
|
988
|
+
body {
|
|
989
|
+
width: 100%;
|
|
990
|
+
height: 100%;
|
|
991
|
+
margin: 0;
|
|
992
|
+
background: var(--theme-term-bg, Canvas);
|
|
993
|
+
}
|
|
994
|
+
aitty-snapshot {
|
|
995
|
+
display: block;
|
|
996
|
+
width: 100%;
|
|
997
|
+
height: 100%;
|
|
998
|
+
}`;
|
|
999
|
+
}
|
|
1000
|
+
//#endregion
|
|
1001
|
+
//#region src/agent/report_artifact_paths.ts
|
|
1002
|
+
function artifactViewerPath(artifact) {
|
|
1003
|
+
if (artifact.kind === "terminal") return artifact.path.endsWith(".terminal.txt") ? artifact.path.replace(/\.terminal\.txt$/, ".terminal.viewer.html") : `${artifact.path}.viewer.html`;
|
|
1004
|
+
if (artifact.kind === "dom") return artifact.path.endsWith(".dom.html") ? artifact.path.replace(/\.dom\.html$/, ".dom.viewer.html") : `${artifact.path}.viewer.html`;
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
function artifactDomPreviewPath(artifact) {
|
|
1008
|
+
return artifact.path.endsWith(".dom.html") ? artifact.path.replace(/\.dom\.html$/, ".dom.preview.html") : `${artifact.path}.preview.html`;
|
|
1009
|
+
}
|
|
1010
|
+
function artifactSnapshotKey(artifact) {
|
|
1011
|
+
return `${artifact.viewport}\u0000${artifact.name}`;
|
|
1012
|
+
}
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/agent/report_dom_artifact_viewer.ts
|
|
1015
|
+
function resolveReportDomPreview(path) {
|
|
1016
|
+
return path ? { path } : null;
|
|
1017
|
+
}
|
|
1018
|
+
function renderDomArtifactViewer(args) {
|
|
1019
|
+
const { artifactsDir, mobile, preview, viewerPath, viewOptions } = args;
|
|
1020
|
+
const src = relativeHref(viewerPath, preview.path, artifactsDir);
|
|
1021
|
+
return `<div class="viewer-viewport dom-viewport" data-mobile="${mobile ? "true" : "false"}" data-screen-mode="${escapeAttribute(viewOptions.screenMode)}"><iframe class="dom-viewer-frame" sandbox="allow-same-origin allow-scripts" src="${escapeAttribute(src)}" title="terminal DOM artifact"></iframe></div>`;
|
|
1022
|
+
}
|
|
1023
|
+
function renderDomArtifactViewerFragment(args) {
|
|
1024
|
+
return {
|
|
1025
|
+
css: "",
|
|
1026
|
+
html: renderDomArtifactViewer(args),
|
|
1027
|
+
script: ""
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/agent/report_raw_ansi_text.ts
|
|
1032
|
+
function renderRawAnsiTextHtml(input) {
|
|
1033
|
+
const out = [];
|
|
1034
|
+
let style = {};
|
|
1035
|
+
let segmentStart = 0;
|
|
1036
|
+
let index = 0;
|
|
1037
|
+
while (index < input.length) {
|
|
1038
|
+
if (!isCsiStart(input, index)) {
|
|
1039
|
+
index += 1;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const end = findCsiEnd(input, index + 2);
|
|
1043
|
+
if (end === -1) {
|
|
1044
|
+
index += 1;
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
out.push(renderAnsiTextSegment(input.slice(segmentStart, index), style));
|
|
1048
|
+
if (input[end] === "m") style = applySgrCodes(style, parseSgrCodes(input.slice(index + 2, end)));
|
|
1049
|
+
index = end + 1;
|
|
1050
|
+
segmentStart = index;
|
|
1051
|
+
}
|
|
1052
|
+
out.push(renderAnsiTextSegment(input.slice(segmentStart), style));
|
|
1053
|
+
return out.join("");
|
|
1054
|
+
}
|
|
1055
|
+
function isCsiStart(input, index) {
|
|
1056
|
+
return input.charCodeAt(index) === 27 && input[index + 1] === "[";
|
|
1057
|
+
}
|
|
1058
|
+
function findCsiEnd(input, start) {
|
|
1059
|
+
for (let index = start; index < input.length; index += 1) {
|
|
1060
|
+
const code = input.charCodeAt(index);
|
|
1061
|
+
if (code >= 64 && code <= 126) return index;
|
|
1062
|
+
}
|
|
1063
|
+
return -1;
|
|
1064
|
+
}
|
|
1065
|
+
function parseSgrCodes(input) {
|
|
1066
|
+
if (!input) return [0];
|
|
1067
|
+
return input.split(";").map((value) => {
|
|
1068
|
+
const parsed = Number.parseInt(value, 10);
|
|
1069
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
function applySgrCodes(current, codes) {
|
|
1073
|
+
const next = { ...current };
|
|
1074
|
+
for (let index = 0; index < codes.length; index += 1) {
|
|
1075
|
+
const code = codes[index] ?? 0;
|
|
1076
|
+
if (code === 0) {
|
|
1077
|
+
resetStyle(next);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (code === 1) next.bold = true;
|
|
1081
|
+
else if (code === 2) next.dim = true;
|
|
1082
|
+
else if (code === 3) next.italic = true;
|
|
1083
|
+
else if (code === 4) next.underline = true;
|
|
1084
|
+
else if (code === 7) next.inverse = true;
|
|
1085
|
+
else if (code === 9) next.strikethrough = true;
|
|
1086
|
+
else if (code === 22) {
|
|
1087
|
+
next.bold = false;
|
|
1088
|
+
next.dim = false;
|
|
1089
|
+
} else if (code === 23) next.italic = false;
|
|
1090
|
+
else if (code === 24) next.underline = false;
|
|
1091
|
+
else if (code === 27) next.inverse = false;
|
|
1092
|
+
else if (code === 29) next.strikethrough = false;
|
|
1093
|
+
else if (code === 39) next.fg = void 0;
|
|
1094
|
+
else if (code === 49) next.bg = void 0;
|
|
1095
|
+
else if (code >= 30 && code <= 37) next.fg = ansiPaletteColor(code - 30);
|
|
1096
|
+
else if (code >= 90 && code <= 97) next.fg = ansiPaletteColor(code - 90 + 8);
|
|
1097
|
+
else if (code >= 40 && code <= 47) next.bg = ansiPaletteColor(code - 40);
|
|
1098
|
+
else if (code >= 100 && code <= 107) next.bg = ansiPaletteColor(code - 100 + 8);
|
|
1099
|
+
else if ((code === 38 || code === 48) && codes[index + 1] === 5) {
|
|
1100
|
+
const color = ansiPaletteColor(codes[index + 2] ?? 0);
|
|
1101
|
+
if (code === 38) next.fg = color;
|
|
1102
|
+
else next.bg = color;
|
|
1103
|
+
index += 2;
|
|
1104
|
+
} else if ((code === 38 || code === 48) && codes[index + 1] === 2) {
|
|
1105
|
+
const color = `rgb(${clampColor(codes[index + 2] ?? 0)} ${clampColor(codes[index + 3] ?? 0)} ${clampColor(codes[index + 4] ?? 0)})`;
|
|
1106
|
+
if (code === 38) next.fg = color;
|
|
1107
|
+
else next.bg = color;
|
|
1108
|
+
index += 4;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return next;
|
|
1112
|
+
}
|
|
1113
|
+
function resetStyle(style) {
|
|
1114
|
+
style.fg = void 0;
|
|
1115
|
+
style.bg = void 0;
|
|
1116
|
+
style.bold = false;
|
|
1117
|
+
style.dim = false;
|
|
1118
|
+
style.italic = false;
|
|
1119
|
+
style.underline = false;
|
|
1120
|
+
style.inverse = false;
|
|
1121
|
+
style.strikethrough = false;
|
|
1122
|
+
}
|
|
1123
|
+
function renderAnsiTextSegment(text, style) {
|
|
1124
|
+
if (!text) return "";
|
|
1125
|
+
const safeText = escapeHtml(text);
|
|
1126
|
+
const css = ansiStyleToCss(style);
|
|
1127
|
+
return css ? `<span style="${escapeAttribute(css)}">${safeText}</span>` : safeText;
|
|
1128
|
+
}
|
|
1129
|
+
function ansiStyleToCss(style) {
|
|
1130
|
+
const decls = [];
|
|
1131
|
+
const fg = style.inverse ? style.bg : style.fg;
|
|
1132
|
+
const bg = style.inverse ? style.fg : style.bg;
|
|
1133
|
+
if (fg) decls.push(`color: ${fg}`);
|
|
1134
|
+
if (bg) decls.push(`background-color: ${bg}`);
|
|
1135
|
+
if (style.bold) decls.push("font-weight: 700");
|
|
1136
|
+
if (style.italic) decls.push("font-style: italic");
|
|
1137
|
+
if (style.dim) decls.push("opacity: 0.72");
|
|
1138
|
+
const decorations = [];
|
|
1139
|
+
if (style.underline) decorations.push("underline");
|
|
1140
|
+
if (style.strikethrough) decorations.push("line-through");
|
|
1141
|
+
if (decorations.length > 0) decls.push(`text-decoration: ${decorations.join(" ")}`);
|
|
1142
|
+
return decls.join("; ");
|
|
1143
|
+
}
|
|
1144
|
+
function ansiPaletteColor(index) {
|
|
1145
|
+
const table16 = [
|
|
1146
|
+
"#151b23",
|
|
1147
|
+
"#ff7b72",
|
|
1148
|
+
"#7ee787",
|
|
1149
|
+
"#f2cc60",
|
|
1150
|
+
"#79c0ff",
|
|
1151
|
+
"#d2a8ff",
|
|
1152
|
+
"#70e1e8",
|
|
1153
|
+
"#e6edf7",
|
|
1154
|
+
"#6e7681",
|
|
1155
|
+
"#ffa198",
|
|
1156
|
+
"#aff5b4",
|
|
1157
|
+
"#ffdf80",
|
|
1158
|
+
"#a5d6ff",
|
|
1159
|
+
"#e2c5ff",
|
|
1160
|
+
"#96f0f5",
|
|
1161
|
+
"#ffffff"
|
|
1162
|
+
];
|
|
1163
|
+
const normalized = clampColor(index);
|
|
1164
|
+
if (normalized < table16.length) return table16[normalized] ?? "#e6edf7";
|
|
1165
|
+
if (normalized <= 231) {
|
|
1166
|
+
const values = [
|
|
1167
|
+
0,
|
|
1168
|
+
95,
|
|
1169
|
+
135,
|
|
1170
|
+
175,
|
|
1171
|
+
215,
|
|
1172
|
+
255
|
|
1173
|
+
];
|
|
1174
|
+
const offset = normalized - 16;
|
|
1175
|
+
return `rgb(${values[Math.trunc(offset / 36) % 6] ?? 0} ${values[Math.trunc(offset / 6) % 6] ?? 0} ${values[offset % 6] ?? 0})`;
|
|
1176
|
+
}
|
|
1177
|
+
const gray = clampColor(8 + (normalized - 232) * 10);
|
|
1178
|
+
return `rgb(${gray} ${gray} ${gray})`;
|
|
1179
|
+
}
|
|
1180
|
+
function clampColor(value) {
|
|
1181
|
+
if (!Number.isFinite(value)) return 0;
|
|
1182
|
+
return Math.max(0, Math.min(255, Math.trunc(value)));
|
|
1183
|
+
}
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/agent/report_viewer_pan.ts
|
|
1186
|
+
function renderReportViewportPanCss() {
|
|
1187
|
+
return ` [data-ptywright-report-pan="true"] {
|
|
1188
|
+
cursor: grab;
|
|
1189
|
+
}
|
|
1190
|
+
[data-ptywright-report-panning="true"] {
|
|
1191
|
+
cursor: grabbing;
|
|
1192
|
+
}`;
|
|
1193
|
+
}
|
|
1194
|
+
function renderReportViewportPanScript(body) {
|
|
1195
|
+
return ` (() => {
|
|
1196
|
+
const isHtmlElement = (value) => {
|
|
1197
|
+
return Boolean(
|
|
1198
|
+
value &&
|
|
1199
|
+
value.nodeType === 1 &&
|
|
1200
|
+
value.dataset &&
|
|
1201
|
+
value.style &&
|
|
1202
|
+
typeof value.addEventListener === "function"
|
|
1203
|
+
);
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const scrollToBottom = (element) => {
|
|
1207
|
+
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const enableViewportPan = (element) => {
|
|
1211
|
+
if (!isHtmlElement(element) || element.dataset.ptywrightReportPan === "true") {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
element.dataset.ptywrightReportPan = "true";
|
|
1216
|
+
let activePointerId = null;
|
|
1217
|
+
let startX = 0;
|
|
1218
|
+
let startY = 0;
|
|
1219
|
+
let startScrollLeft = 0;
|
|
1220
|
+
let startScrollTop = 0;
|
|
1221
|
+
let moved = false;
|
|
1222
|
+
|
|
1223
|
+
element.addEventListener("pointerdown", (event) => {
|
|
1224
|
+
if (event.button !== 0) return;
|
|
1225
|
+
activePointerId = event.pointerId;
|
|
1226
|
+
startX = event.clientX;
|
|
1227
|
+
startY = event.clientY;
|
|
1228
|
+
startScrollLeft = element.scrollLeft;
|
|
1229
|
+
startScrollTop = element.scrollTop;
|
|
1230
|
+
moved = false;
|
|
1231
|
+
element.dataset.ptywrightReportPanning = "true";
|
|
1232
|
+
element.setPointerCapture?.(event.pointerId);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
element.addEventListener("pointermove", (event) => {
|
|
1236
|
+
if (activePointerId !== event.pointerId) return;
|
|
1237
|
+
const deltaX = event.clientX - startX;
|
|
1238
|
+
const deltaY = event.clientY - startY;
|
|
1239
|
+
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
|
|
1240
|
+
moved = true;
|
|
1241
|
+
}
|
|
1242
|
+
element.scrollLeft = startScrollLeft - deltaX;
|
|
1243
|
+
element.scrollTop = startScrollTop - deltaY;
|
|
1244
|
+
if (moved) event.preventDefault();
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
const finish = (event) => {
|
|
1248
|
+
if (activePointerId !== event.pointerId) return;
|
|
1249
|
+
activePointerId = null;
|
|
1250
|
+
element.dataset.ptywrightReportPanning = "false";
|
|
1251
|
+
element.releasePointerCapture?.(event.pointerId);
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
element.addEventListener("pointerup", finish);
|
|
1255
|
+
element.addEventListener("pointercancel", finish);
|
|
1256
|
+
};
|
|
1257
|
+
${body}
|
|
1258
|
+
})();`;
|
|
1259
|
+
}
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/agent/report_raw_artifact_viewer.ts
|
|
1262
|
+
function renderRawArtifactViewer(content, mobile, viewOptions) {
|
|
1263
|
+
return `<div class="viewer-viewport raw-artifact-viewport" data-mobile="${mobile ? "true" : "false"}" data-screen-mode="${escapeAttribute(viewOptions.screenMode)}"><pre class="raw-artifact-text">${renderRawAnsiTextHtml(content)}</pre></div>`;
|
|
1264
|
+
}
|
|
1265
|
+
function renderRawArtifactViewerCss() {
|
|
1266
|
+
return ` .raw-artifact-text {
|
|
1267
|
+
min-width: 100%;
|
|
1268
|
+
width: max-content;
|
|
1269
|
+
min-height: 100%;
|
|
1270
|
+
margin: 0;
|
|
1271
|
+
background:
|
|
1272
|
+
linear-gradient(180deg, color-mix(in srgb, #162033 92%, black), #0c111d);
|
|
1273
|
+
color: #e6edf7;
|
|
1274
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1275
|
+
font-size: 13px;
|
|
1276
|
+
line-height: 1.45;
|
|
1277
|
+
overflow: visible;
|
|
1278
|
+
padding: 12px;
|
|
1279
|
+
white-space: pre;
|
|
1280
|
+
}
|
|
1281
|
+
.viewer-page[data-theme="light"] .raw-artifact-text {
|
|
1282
|
+
background: #ffffff;
|
|
1283
|
+
color: #4c4f69;
|
|
1284
|
+
}
|
|
1285
|
+
${renderReportViewportPanCss()}`;
|
|
1286
|
+
}
|
|
1287
|
+
function renderRawArtifactViewerScript() {
|
|
1288
|
+
return renderReportViewportPanScript(`
|
|
1289
|
+
document.querySelectorAll(".raw-artifact-viewport").forEach((viewport) => {
|
|
1290
|
+
enableViewportPan(viewport);
|
|
1291
|
+
if (viewport.getAttribute("data-screen-mode") === "termvision") {
|
|
1292
|
+
requestAnimationFrame(() => scrollToBottom(viewport));
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
`);
|
|
1296
|
+
}
|
|
1297
|
+
function renderRawArtifactViewerFragment(args) {
|
|
1298
|
+
const { content, mobile, viewOptions } = args;
|
|
1299
|
+
return {
|
|
1300
|
+
css: renderRawArtifactViewerCss(),
|
|
1301
|
+
html: renderRawArtifactViewer(content, mobile, viewOptions),
|
|
1302
|
+
script: renderRawArtifactViewerScript()
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
//#endregion
|
|
1306
|
+
//#region src/agent/report_artifact_viewer_fragment.ts
|
|
1307
|
+
function renderArtifactViewerFragment(args) {
|
|
1308
|
+
const { artifactsDir, content, domViewerPreview, mobile, viewerPath, viewOptions } = args;
|
|
1309
|
+
if (domViewerPreview) return renderDomArtifactViewerFragment({
|
|
1310
|
+
artifactsDir,
|
|
1311
|
+
mobile,
|
|
1312
|
+
preview: domViewerPreview,
|
|
1313
|
+
viewerPath,
|
|
1314
|
+
viewOptions
|
|
1315
|
+
});
|
|
1316
|
+
return renderRawArtifactViewerFragment({
|
|
1317
|
+
content,
|
|
1318
|
+
mobile,
|
|
1319
|
+
viewOptions
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/agent/report_artifact_viewer_shell_css.ts
|
|
1324
|
+
function renderArtifactViewerShellCss() {
|
|
1325
|
+
return ` :root {
|
|
1326
|
+
color-scheme: dark;
|
|
1327
|
+
--bg: #080d16;
|
|
1328
|
+
--panel: #0c111d;
|
|
1329
|
+
--line: rgba(148, 163, 184, 0.26);
|
|
1330
|
+
--ink: #e6edf7;
|
|
1331
|
+
--muted: #91a0b8;
|
|
1332
|
+
--focus: #79c0ff;
|
|
1333
|
+
font-family:
|
|
1334
|
+
ui-sans-serif,
|
|
1335
|
+
system-ui,
|
|
1336
|
+
-apple-system,
|
|
1337
|
+
BlinkMacSystemFont,
|
|
1338
|
+
"Segoe UI",
|
|
1339
|
+
sans-serif;
|
|
1340
|
+
}
|
|
1341
|
+
* {
|
|
1342
|
+
box-sizing: border-box;
|
|
1343
|
+
}
|
|
1344
|
+
html,
|
|
1345
|
+
body {
|
|
1346
|
+
width: 100%;
|
|
1347
|
+
height: 100%;
|
|
1348
|
+
margin: 0;
|
|
1349
|
+
overflow: hidden;
|
|
1350
|
+
background: var(--bg);
|
|
1351
|
+
color: var(--ink);
|
|
1352
|
+
}
|
|
1353
|
+
.viewer-page {
|
|
1354
|
+
display: grid;
|
|
1355
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
1356
|
+
width: 100%;
|
|
1357
|
+
height: 100dvh;
|
|
1358
|
+
min-width: 0;
|
|
1359
|
+
min-height: 0;
|
|
1360
|
+
}
|
|
1361
|
+
.viewer-toolbar {
|
|
1362
|
+
display: flex;
|
|
1363
|
+
flex-wrap: wrap;
|
|
1364
|
+
gap: 8px;
|
|
1365
|
+
align-items: center;
|
|
1366
|
+
min-width: 0;
|
|
1367
|
+
border-bottom: 1px solid var(--line);
|
|
1368
|
+
background: color-mix(in srgb, var(--panel) 88%, black);
|
|
1369
|
+
padding: 10px 12px;
|
|
1370
|
+
}
|
|
1371
|
+
.viewer-title {
|
|
1372
|
+
min-width: min(100%, 220px);
|
|
1373
|
+
margin-right: auto;
|
|
1374
|
+
overflow-wrap: anywhere;
|
|
1375
|
+
font-size: 14px;
|
|
1376
|
+
font-weight: 700;
|
|
1377
|
+
}
|
|
1378
|
+
.viewer-link,
|
|
1379
|
+
.viewer-pill {
|
|
1380
|
+
display: inline-flex;
|
|
1381
|
+
min-height: 30px;
|
|
1117
1382
|
align-items: center;
|
|
1118
1383
|
border: 1px solid var(--line);
|
|
1119
1384
|
border-radius: 999px;
|
|
1120
|
-
padding: 0
|
|
1385
|
+
padding: 0 10px;
|
|
1121
1386
|
color: var(--muted);
|
|
1122
|
-
font-size:
|
|
1387
|
+
font-size: 12px;
|
|
1388
|
+
text-decoration: none;
|
|
1123
1389
|
}
|
|
1124
|
-
.
|
|
1125
|
-
|
|
1126
|
-
color: ${result.ok ? "var(--good)" : "var(--bad)"};
|
|
1390
|
+
.viewer-link {
|
|
1391
|
+
color: var(--focus);
|
|
1127
1392
|
font-weight: 700;
|
|
1128
1393
|
}
|
|
1129
|
-
.
|
|
1394
|
+
.viewer-stage {
|
|
1395
|
+
display: grid;
|
|
1396
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1397
|
+
grid-template-rows: minmax(0, 1fr);
|
|
1398
|
+
min-width: 0;
|
|
1399
|
+
min-height: 0;
|
|
1400
|
+
justify-items: center;
|
|
1401
|
+
align-content: start;
|
|
1402
|
+
overflow: hidden;
|
|
1403
|
+
background:
|
|
1404
|
+
radial-gradient(circle at top left, rgba(121, 192, 255, 0.1), transparent 32%),
|
|
1405
|
+
#060a13;
|
|
1406
|
+
padding: 14px;
|
|
1407
|
+
}
|
|
1408
|
+
.viewer-viewport {
|
|
1409
|
+
width: min(var(--config-viewport-width), 100%);
|
|
1410
|
+
height: min(var(--config-viewport-height), 100%);
|
|
1411
|
+
max-width: 100%;
|
|
1412
|
+
max-height: 100%;
|
|
1413
|
+
overflow: auto;
|
|
1414
|
+
overscroll-behavior: contain;
|
|
1415
|
+
border: 0;
|
|
1416
|
+
border-radius: 8px;
|
|
1417
|
+
background: #0c111d;
|
|
1418
|
+
outline: 1px solid var(--line);
|
|
1419
|
+
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.34);
|
|
1420
|
+
}
|
|
1421
|
+
.viewer-viewport[data-mobile="true"] {
|
|
1422
|
+
width: min(var(--config-viewport-width), 100%);
|
|
1423
|
+
}
|
|
1424
|
+
.dom-viewport {
|
|
1425
|
+
overflow: hidden;
|
|
1426
|
+
}
|
|
1427
|
+
.dom-viewer-frame {
|
|
1428
|
+
display: block;
|
|
1429
|
+
width: 100%;
|
|
1430
|
+
height: 100%;
|
|
1431
|
+
border: 0;
|
|
1432
|
+
background: #0c111d;
|
|
1433
|
+
}
|
|
1434
|
+
.viewer-page[data-theme="light"] {
|
|
1435
|
+
--bg: #f8fafc;
|
|
1436
|
+
--panel: #ffffff;
|
|
1437
|
+
--line: rgba(15, 23, 42, 0.14);
|
|
1438
|
+
--ink: #0f172a;
|
|
1439
|
+
--muted: #64748b;
|
|
1440
|
+
--focus: #1e66f5;
|
|
1441
|
+
}
|
|
1442
|
+
.viewer-page[data-theme="light"] .viewer-stage {
|
|
1443
|
+
background: #f8fafc;
|
|
1444
|
+
}
|
|
1445
|
+
.viewer-page[data-theme="light"] .viewer-viewport,
|
|
1446
|
+
.viewer-page[data-theme="light"] .dom-viewer-frame {
|
|
1447
|
+
background: #ffffff;
|
|
1448
|
+
}
|
|
1449
|
+
@media (max-width: 720px) {
|
|
1450
|
+
.viewer-toolbar {
|
|
1451
|
+
padding: 8px;
|
|
1452
|
+
}
|
|
1453
|
+
.viewer-title {
|
|
1454
|
+
flex-basis: 100%;
|
|
1455
|
+
order: -1;
|
|
1456
|
+
}
|
|
1457
|
+
.viewer-stage {
|
|
1458
|
+
padding: 0;
|
|
1459
|
+
}
|
|
1460
|
+
.viewer-viewport {
|
|
1461
|
+
width: min(var(--config-viewport-width), 100%);
|
|
1462
|
+
height: min(var(--config-viewport-height), 100%);
|
|
1463
|
+
border-radius: 0;
|
|
1464
|
+
}
|
|
1465
|
+
}`;
|
|
1466
|
+
}
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/agent/report_artifact_viewer_shell.ts
|
|
1469
|
+
function renderArtifactViewerShellHtml(args) {
|
|
1470
|
+
const { artifact, artifactsDir, contentFragment, mobile, reportPath, viewOptions, viewerPath } = args;
|
|
1471
|
+
const title = `${artifact.viewport} ${artifact.name} ${artifact.kind}`;
|
|
1472
|
+
const viewportStyle = renderConfiguredViewportStyle(args.viewport);
|
|
1473
|
+
const viewportLabel = args.viewport ? `${args.viewport.name} ${args.viewport.width}x${args.viewport.height}` : `${artifact.viewport} viewport`;
|
|
1474
|
+
const backHref = relativeHref(viewerPath, reportPath, artifactsDir);
|
|
1475
|
+
const rawHref = relativeHref(viewerPath, artifact.path, artifactsDir);
|
|
1476
|
+
const baselineHref = artifact.baselinePath ? relativeHref(viewerPath, artifact.baselinePath, artifactsDir) : "";
|
|
1477
|
+
const diffHref = artifact.diffPath ? relativeHref(viewerPath, artifact.diffPath, artifactsDir) : "";
|
|
1478
|
+
const scriptTag = contentFragment.script ? ` <script>
|
|
1479
|
+
${contentFragment.script}
|
|
1480
|
+
<\/script>
|
|
1481
|
+
` : "";
|
|
1482
|
+
return `<!doctype html>
|
|
1483
|
+
<html lang="en" data-theme="${escapeAttribute(viewOptions.theme)}">
|
|
1484
|
+
<head>
|
|
1485
|
+
<meta charset="utf-8" />
|
|
1486
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1487
|
+
<title>${escapeHtml(title)}</title>
|
|
1488
|
+
<style>
|
|
1489
|
+
${renderArtifactViewerShellCss()}
|
|
1490
|
+
${contentFragment.css}
|
|
1491
|
+
</style>
|
|
1492
|
+
</head>
|
|
1493
|
+
<body>
|
|
1494
|
+
<main class="viewer-page" data-report-screen-mode="${escapeAttribute(viewOptions.screenMode)}" data-theme="${escapeAttribute(viewOptions.theme)}" style="${escapeAttribute(viewportStyle)}">
|
|
1495
|
+
<header class="viewer-toolbar">
|
|
1496
|
+
<a class="viewer-link" href="${escapeAttribute(backHref)}">Report</a>
|
|
1497
|
+
<span class="viewer-title">${escapeHtml(title)}</span>
|
|
1498
|
+
<span class="viewer-pill">${escapeHtml(viewportLabel)}</span>
|
|
1499
|
+
<span class="viewer-pill">${escapeHtml(viewOptions.screenMode)}</span>
|
|
1500
|
+
<a class="viewer-link" href="${escapeAttribute(rawHref)}">Raw</a>
|
|
1501
|
+
${baselineHref ? `<a class="viewer-link" href="${escapeAttribute(baselineHref)}">Baseline</a>` : ""}
|
|
1502
|
+
${diffHref ? `<a class="viewer-link" href="${escapeAttribute(diffHref)}">Diff</a>` : ""}
|
|
1503
|
+
</header>
|
|
1504
|
+
<section class="viewer-stage" data-mobile="${mobile ? "true" : "false"}">
|
|
1505
|
+
${contentFragment.html}
|
|
1506
|
+
</section>
|
|
1507
|
+
</main>
|
|
1508
|
+
${scriptTag}
|
|
1509
|
+
</body>
|
|
1510
|
+
</html>`;
|
|
1511
|
+
}
|
|
1512
|
+
function renderConfiguredViewportStyle(viewport) {
|
|
1513
|
+
if (!viewport) return "--config-viewport-width: 1280px; --config-viewport-height: 760px;";
|
|
1514
|
+
return `--config-viewport-width: ${viewport.width}px; --config-viewport-height: ${viewport.height}px;`;
|
|
1515
|
+
}
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/agent/report_launch_args.ts
|
|
1518
|
+
function readReportLaunchArgSets(result) {
|
|
1519
|
+
return [readFlowLaunchArgs(result.flowPath), readCassetteLaunchArgs(result.replaySourceCassettePath ?? result.cassettePath)];
|
|
1520
|
+
}
|
|
1521
|
+
function readFlagValueFromArgSets(argSets, flag) {
|
|
1522
|
+
for (const args of argSets) {
|
|
1523
|
+
const value = readFlagValue(args, flag);
|
|
1524
|
+
if (value !== void 0) return value;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function readFlagValue(args, flag) {
|
|
1528
|
+
const index = args.indexOf(flag);
|
|
1529
|
+
return index >= 0 ? args[index + 1] : void 0;
|
|
1530
|
+
}
|
|
1531
|
+
function readCassetteLaunchArgs(cassettePath) {
|
|
1532
|
+
try {
|
|
1533
|
+
return normalizeStringArray(JSON.parse(readFileSync(cassettePath, "utf8")).spec?.launch?.args);
|
|
1534
|
+
} catch {
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
function readFlowLaunchArgs(flowPath) {
|
|
1539
|
+
try {
|
|
1540
|
+
return normalizeStringArray(JSON.parse(readFileSync(flowPath, "utf8")).launch?.args);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return [];
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
function normalizeStringArray(value) {
|
|
1546
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
1547
|
+
}
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/agent/report_view_options.ts
|
|
1550
|
+
function isMobileViewport(viewport) {
|
|
1551
|
+
return Boolean(viewport?.isMobile || viewport?.hasTouch || (viewport?.width ?? 9999) <= 720);
|
|
1552
|
+
}
|
|
1553
|
+
function resolveReportViewOptions(result, config) {
|
|
1554
|
+
const launchArgSets = readReportLaunchArgSets(result);
|
|
1555
|
+
const ptyReplayArg = readFlagValueFromArgSets(launchArgSets, "--pty-replay");
|
|
1556
|
+
const screenModeArg = readFlagValueFromArgSets(launchArgSets, "--experimental-screen-mode");
|
|
1557
|
+
const themeArg = readFlagValueFromArgSets(launchArgSets, "--theme");
|
|
1558
|
+
const fontSizeArg = readFlagValueFromArgSets(launchArgSets, "--font-size");
|
|
1559
|
+
const lineHeightArg = readFlagValueFromArgSets(launchArgSets, "--line-height");
|
|
1560
|
+
const stableFrameConfig = resolveStableFrameConfig$1(config, result.name);
|
|
1561
|
+
const themeOverride = ptyReplayArg && stableFrameConfig.enabled !== false && !stableFrameConfig.skip ? stableFrameConfig.theme : void 0;
|
|
1562
|
+
return {
|
|
1563
|
+
fontSize: parsePositiveNumber(fontSizeArg) ?? 15,
|
|
1564
|
+
lineHeight: parsePositiveNumber(lineHeightArg) ?? 1.6,
|
|
1565
|
+
screenMode: screenModeArg && screenModeArg !== "termvision" ? "plain" : "termvision",
|
|
1566
|
+
theme: themeOverride ?? (themeArg === "light" ? "light" : "dark")
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
function parsePositiveNumber(value) {
|
|
1570
|
+
if (value === void 0) return void 0;
|
|
1571
|
+
const parsed = Number(value);
|
|
1572
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1573
|
+
}
|
|
1574
|
+
function resolveStableFrameConfig$1(config, flowName) {
|
|
1575
|
+
const stableFrames = config?.agent?.report?.stableFrames;
|
|
1576
|
+
const flowConfig = stableFrames?.flows?.[flowName];
|
|
1577
|
+
return {
|
|
1578
|
+
enabled: flowConfig?.enabled ?? stableFrames?.enabled,
|
|
1579
|
+
skip: flowConfig?.skip ?? stableFrames?.skip,
|
|
1580
|
+
theme: flowConfig?.theme ?? stableFrames?.theme ?? "dark"
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/agent/report_artifact_viewer.ts
|
|
1585
|
+
function renderArtifactViewerHtml(args) {
|
|
1586
|
+
const { artifact, artifactsDir, content, domPreview, reportPath, terminalDomPreview, viewOptions, viewerPath, viewport } = args;
|
|
1587
|
+
const mobile = isMobileViewport(viewport);
|
|
1588
|
+
return renderArtifactViewerShellHtml({
|
|
1589
|
+
artifact,
|
|
1590
|
+
artifactsDir,
|
|
1591
|
+
contentFragment: renderArtifactViewerFragment({
|
|
1592
|
+
artifactsDir,
|
|
1593
|
+
content,
|
|
1594
|
+
domViewerPreview: artifact.kind === "dom" ? domPreview : artifact.kind === "terminal" ? terminalDomPreview : null,
|
|
1595
|
+
mobile,
|
|
1596
|
+
viewerPath,
|
|
1597
|
+
viewOptions
|
|
1598
|
+
}),
|
|
1599
|
+
mobile,
|
|
1600
|
+
reportPath,
|
|
1601
|
+
viewOptions,
|
|
1602
|
+
viewerPath,
|
|
1603
|
+
viewport
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/agent/report_pty_stable_frame.ts
|
|
1608
|
+
async function extractPtyReplayStableFrameForReport(args) {
|
|
1609
|
+
const replayPathArg = readFlagValueFromArgSets(readReportLaunchArgSets(args.result), "--pty-replay");
|
|
1610
|
+
if (!replayPathArg) return null;
|
|
1611
|
+
const config = resolveStableFrameConfig(args.config, args.result.name);
|
|
1612
|
+
if (config.enabled === false || config.skip) return null;
|
|
1613
|
+
const replayPath = resolvePtyReplayPath(replayPathArg, args.config);
|
|
1614
|
+
if (!existsSync(replayPath)) return null;
|
|
1615
|
+
const recording = JSON.parse(readFileSync(replayPath, "utf8"));
|
|
1616
|
+
const events = Array.isArray(recording.events) ? recording.events : [];
|
|
1617
|
+
const firstResize = events.find((event) => event.type === "resize");
|
|
1618
|
+
const terminal = new Terminal({
|
|
1619
|
+
allowProposedApi: true,
|
|
1620
|
+
cols: config.cols ?? recording.terminal?.cols ?? recording.command?.cols ?? recording.cols ?? firstResize?.cols ?? 80,
|
|
1621
|
+
convertEol: true,
|
|
1622
|
+
rows: config.rows ?? recording.terminal?.rows ?? recording.command?.rows ?? recording.rows ?? firstResize?.rows ?? 24,
|
|
1623
|
+
scrollback: 2e4
|
|
1624
|
+
});
|
|
1625
|
+
try {
|
|
1626
|
+
return await extractStableFrame({
|
|
1627
|
+
config,
|
|
1628
|
+
events,
|
|
1629
|
+
terminal
|
|
1630
|
+
});
|
|
1631
|
+
} finally {
|
|
1632
|
+
terminal.dispose();
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
function renderPtyReplayStableFramePreviewDocument(args) {
|
|
1636
|
+
const targetCols = resolveTargetCols({
|
|
1637
|
+
config: resolveStableFrameConfig(args.config, args.flowName),
|
|
1638
|
+
fontSize: args.viewOptions.fontSize,
|
|
1639
|
+
frameCols: args.frame.cols,
|
|
1640
|
+
viewport: args.viewport
|
|
1641
|
+
});
|
|
1642
|
+
const body = renderAittyPreviewBody({
|
|
1643
|
+
snapshot: renderStableFrameDom(args.frame, targetCols),
|
|
1644
|
+
snapshotLayout: {
|
|
1645
|
+
cols: targetCols,
|
|
1646
|
+
fontSize: args.viewOptions.fontSize,
|
|
1647
|
+
lineHeight: args.viewOptions.lineHeight,
|
|
1648
|
+
paddingInline: isMobileViewport(args.viewport) ? 16 : 32,
|
|
1649
|
+
rows: args.frame.rows
|
|
1650
|
+
},
|
|
1651
|
+
viewOptions: args.viewOptions
|
|
1652
|
+
});
|
|
1653
|
+
const style = renderAittyPreviewCss(args.viewOptions);
|
|
1654
|
+
const assetTags = renderAittyPreviewAssetTags(args.aittyAssets);
|
|
1655
|
+
return `<!doctype html>
|
|
1656
|
+
<html lang="en" data-theme="${escapeAttribute(args.viewOptions.theme)}">
|
|
1657
|
+
<head>
|
|
1658
|
+
<meta charset="utf-8" />
|
|
1659
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
1660
|
+
<title>stable-frame preview (atMs=${args.frame.atMs})</title>
|
|
1661
|
+
${assetTags} <style>
|
|
1662
|
+
${style}
|
|
1663
|
+
</style>
|
|
1664
|
+
</head>
|
|
1665
|
+
<body>
|
|
1666
|
+
${body}
|
|
1667
|
+
</body>
|
|
1668
|
+
</html>`;
|
|
1669
|
+
}
|
|
1670
|
+
function resolveStableFrameConfig(config, flowName) {
|
|
1671
|
+
const stableFrames = config?.agent?.report?.stableFrames;
|
|
1672
|
+
const flowConfig = stableFrames?.flows?.[flowName];
|
|
1673
|
+
return {
|
|
1674
|
+
...stableFrames,
|
|
1675
|
+
...flowConfig,
|
|
1676
|
+
stableMs: flowConfig?.stableMs ?? stableFrames?.stableMs ?? 200,
|
|
1677
|
+
theme: flowConfig?.theme ?? stableFrames?.theme ?? "dark",
|
|
1678
|
+
viewportOnly: flowConfig?.viewportOnly ?? stableFrames?.viewportOnly ?? false,
|
|
1679
|
+
viewportTargets: {
|
|
1680
|
+
...stableFrames?.viewportTargets,
|
|
1681
|
+
...flowConfig?.viewportTargets
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
function resolvePtyReplayPath(replayPathArg, config) {
|
|
1686
|
+
if (isAbsolute(replayPathArg)) return replayPathArg;
|
|
1687
|
+
return resolve(config?.rootDir ?? process.cwd(), replayPathArg);
|
|
1688
|
+
}
|
|
1689
|
+
async function extractStableFrame(args) {
|
|
1690
|
+
const { config, events, terminal } = args;
|
|
1691
|
+
const targetFrameIndex = config.frameIndex;
|
|
1692
|
+
const matcher = createStableFrameMatcher(config);
|
|
1693
|
+
let matchedFrame = null;
|
|
1694
|
+
const retainedFrames = [];
|
|
1695
|
+
const retainCount = targetFrameIndex === void 0 ? 24 : targetFrameIndex < 0 ? -targetFrameIndex : 1;
|
|
1696
|
+
let totalFrames = 0;
|
|
1697
|
+
let pendingDirtySinceMs = null;
|
|
1698
|
+
let lastEventAtMs = 0;
|
|
1699
|
+
const captureFrame = (atMs, reason) => {
|
|
1700
|
+
const shouldRetain = targetFrameIndex === void 0 || targetFrameIndex >= 0 && totalFrames === targetFrameIndex || targetFrameIndex < 0;
|
|
1701
|
+
if (shouldRetain || matcher) {
|
|
1702
|
+
const frame = {
|
|
1703
|
+
...snapshotTerminalFrame(terminal, {
|
|
1704
|
+
atMs,
|
|
1705
|
+
index: totalFrames,
|
|
1706
|
+
reason,
|
|
1707
|
+
viewportOnly: config.viewportOnly
|
|
1708
|
+
}),
|
|
1709
|
+
score: scoreTerminalFrame(terminal)
|
|
1710
|
+
};
|
|
1711
|
+
if (matcher?.matches(stableFrameText(frame))) {
|
|
1712
|
+
if (matcher.mode === "last" || !matchedFrame) matchedFrame = frame;
|
|
1713
|
+
}
|
|
1714
|
+
if (shouldRetain) retainedFrames.push(frame);
|
|
1715
|
+
if (retainedFrames.length > retainCount) retainedFrames.splice(0, retainedFrames.length - retainCount);
|
|
1716
|
+
}
|
|
1717
|
+
totalFrames += 1;
|
|
1718
|
+
pendingDirtySinceMs = null;
|
|
1719
|
+
};
|
|
1720
|
+
const flushIfStable = (nowMs) => {
|
|
1721
|
+
if (pendingDirtySinceMs === null) return;
|
|
1722
|
+
if (nowMs - pendingDirtySinceMs >= config.stableMs) captureFrame(pendingDirtySinceMs, "stable");
|
|
1723
|
+
};
|
|
1724
|
+
for (const event of events) {
|
|
1725
|
+
const atMs = event.atMs ?? lastEventAtMs;
|
|
1726
|
+
flushIfStable(atMs);
|
|
1727
|
+
lastEventAtMs = atMs;
|
|
1728
|
+
if (event.type === "output" && event.dataBase64) {
|
|
1729
|
+
await writeTerminal(terminal, base64ToBytes(event.dataBase64));
|
|
1730
|
+
pendingDirtySinceMs = pendingDirtySinceMs ?? atMs;
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
if (event.type === "resize" && event.cols && event.rows) {
|
|
1734
|
+
terminal.resize(event.cols, event.rows);
|
|
1735
|
+
pendingDirtySinceMs = pendingDirtySinceMs ?? atMs;
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (event.type === "exit") flushIfStable(atMs);
|
|
1739
|
+
}
|
|
1740
|
+
flushIfStable(lastEventAtMs + config.stableMs);
|
|
1741
|
+
if (totalFrames === 0) captureFrame(lastEventAtMs, "final");
|
|
1742
|
+
if (targetFrameIndex !== void 0 && targetFrameIndex >= 0) {
|
|
1743
|
+
const exact = retainedFrames.find((frame) => frame.index === targetFrameIndex);
|
|
1744
|
+
if (exact) return exact;
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
if (targetFrameIndex !== void 0 && targetFrameIndex < 0) {
|
|
1748
|
+
const resolved = totalFrames + targetFrameIndex;
|
|
1749
|
+
const exact = retainedFrames.find((frame) => frame.index === resolved);
|
|
1750
|
+
if (exact) return exact;
|
|
1751
|
+
}
|
|
1752
|
+
if (matchedFrame) return matchedFrame;
|
|
1753
|
+
return chooseBestStableFrame(retainedFrames);
|
|
1754
|
+
}
|
|
1755
|
+
function writeTerminal(terminal, data) {
|
|
1756
|
+
return new Promise((resolveWrite) => {
|
|
1757
|
+
terminal.write(data, resolveWrite);
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
function snapshotTerminalFrame(terminal, options) {
|
|
1761
|
+
const buffer = terminal.buffer.active;
|
|
1762
|
+
const startY = options.viewportOnly ? buffer.viewportY : 0;
|
|
1763
|
+
const count = options.viewportOnly ? terminal.rows : buffer.length;
|
|
1764
|
+
const logicalLines = [];
|
|
1765
|
+
for (let offset = 0; offset < count; offset += 1) {
|
|
1766
|
+
const y = startY + offset;
|
|
1767
|
+
const line = buffer.getLine(y);
|
|
1768
|
+
const cells = readStableLineCells(terminal, y);
|
|
1769
|
+
const live = options.viewportOnly || y >= buffer.viewportY;
|
|
1770
|
+
const previous = logicalLines.at(-1);
|
|
1771
|
+
if (line?.isWrapped && previous) {
|
|
1772
|
+
previous.cells.push(...cells);
|
|
1773
|
+
previous.live = previous.live || live;
|
|
1774
|
+
previous.physicalRows += 1;
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
logicalLines.push({
|
|
1778
|
+
cells,
|
|
1779
|
+
live,
|
|
1780
|
+
physicalRows: 1
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
return {
|
|
1784
|
+
atMs: options.atMs,
|
|
1785
|
+
cols: terminal.cols,
|
|
1786
|
+
index: options.index,
|
|
1787
|
+
lines: logicalLines,
|
|
1788
|
+
reason: options.reason,
|
|
1789
|
+
rows: terminal.rows
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
function readStableLineCells(terminal, y) {
|
|
1793
|
+
const buffer = terminal.buffer.active;
|
|
1794
|
+
const nullCell = buffer.getNullCell();
|
|
1795
|
+
const line = buffer.getLine(y);
|
|
1796
|
+
const endCol = findMeaningfulEndCol(line, terminal.cols, nullCell);
|
|
1797
|
+
const cells = [];
|
|
1798
|
+
for (let x = 0; x < endCol; x += 1) {
|
|
1799
|
+
const cell = line?.getCell(x, nullCell);
|
|
1800
|
+
if (!cell) {
|
|
1801
|
+
cells.push({
|
|
1802
|
+
style: DEFAULT_STYLE,
|
|
1803
|
+
text: " ",
|
|
1804
|
+
width: 1
|
|
1805
|
+
});
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
const width = cell.getWidth();
|
|
1809
|
+
if (width === 0) continue;
|
|
1810
|
+
cells.push({
|
|
1811
|
+
style: extractStyle(cell),
|
|
1812
|
+
text: cell.getChars() || " ",
|
|
1813
|
+
width
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
return cells;
|
|
1817
|
+
}
|
|
1818
|
+
function scoreTerminalFrame(terminal) {
|
|
1819
|
+
const buffer = terminal.buffer.active;
|
|
1820
|
+
const nullCell = buffer.getNullCell();
|
|
1821
|
+
let score = 0;
|
|
1822
|
+
for (let y = 0; y < buffer.length; y += 1) {
|
|
1823
|
+
const endCol = findMeaningfulEndCol(buffer.getLine(y), terminal.cols, nullCell);
|
|
1824
|
+
score += endCol;
|
|
1825
|
+
}
|
|
1826
|
+
return score;
|
|
1827
|
+
}
|
|
1828
|
+
function chooseBestStableFrame(frames) {
|
|
1829
|
+
return frames.filter((frame) => frame.score > 0).at(-1) ?? frames.at(-1) ?? null;
|
|
1830
|
+
}
|
|
1831
|
+
function createStableFrameMatcher(config) {
|
|
1832
|
+
const matchText = toStringArray(config.matchText);
|
|
1833
|
+
const matchRegex = toStringArray(config.matchRegex).map((pattern) => {
|
|
1834
|
+
try {
|
|
1835
|
+
return new RegExp(pattern);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
throw new Error(`invalid agent.report.stableFrames.matchRegex pattern ${JSON.stringify(pattern)}: ${String(error)}`);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
if (matchText.length === 0 && matchRegex.length === 0) return null;
|
|
1841
|
+
return {
|
|
1842
|
+
mode: config.matchMode ?? "last",
|
|
1843
|
+
matches: (text) => matchText.some((needle) => text.includes(needle)) || matchRegex.some((regex) => regex.test(text))
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
function stableFrameText(frame) {
|
|
1847
|
+
return frame.lines.map((line) => line.cells.map((cell) => cell.text).join("").trimEnd()).join("\n");
|
|
1848
|
+
}
|
|
1849
|
+
function toStringArray(value) {
|
|
1850
|
+
if (value === void 0) return [];
|
|
1851
|
+
return Array.isArray(value) ? value : [value];
|
|
1852
|
+
}
|
|
1853
|
+
function resolveTargetCols(args) {
|
|
1854
|
+
const explicit = args.viewport ? args.config.viewportTargets?.[args.viewport.name] : void 0;
|
|
1855
|
+
if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) return Math.trunc(explicit);
|
|
1856
|
+
if (explicit === null) return args.frameCols;
|
|
1857
|
+
if (isMobileViewport(args.viewport)) {
|
|
1858
|
+
const width = args.viewport?.width ?? 390;
|
|
1859
|
+
const cellWidth = Math.max(1, args.fontSize * .6);
|
|
1860
|
+
return Math.max(20, Math.floor(width / cellWidth));
|
|
1861
|
+
}
|
|
1862
|
+
return args.frameCols;
|
|
1863
|
+
}
|
|
1864
|
+
function renderStableFrameDom(frame, targetCols) {
|
|
1865
|
+
let html = "";
|
|
1866
|
+
let totalRows = 0;
|
|
1867
|
+
let wideBlockId = 0;
|
|
1868
|
+
for (const line of frame.lines) {
|
|
1869
|
+
const lineCols = Math.max(targetCols, cellsDisplayWidth(line.cells));
|
|
1870
|
+
if (lineCols > targetCols || line.physicalRows > 1) {
|
|
1871
|
+
wideBlockId += 1;
|
|
1872
|
+
html += `<div class="term-wide-row-block" data-aitty-wide-block="true" data-aitty-wide-block-id="${wideBlockId}" data-aitty-wide-block-kind="viewport-pan" style="--aitty-wide-block-cols: ${lineCols}">`;
|
|
1873
|
+
html += renderStableFrameRow(line, lineCols, totalRows);
|
|
1874
|
+
html += "</div>";
|
|
1875
|
+
} else html += renderStableFrameRow(line, targetCols, totalRows);
|
|
1876
|
+
totalRows += 1;
|
|
1877
|
+
}
|
|
1878
|
+
return `<div class="term-grid" data-cols="${targetCols}" data-rows="${totalRows}" style="--term-cols: ${targetCols}; --term-rows: ${totalRows};">${html}</div>`;
|
|
1879
|
+
}
|
|
1880
|
+
function renderStableFrameRow(line, lineCols, lineIndex) {
|
|
1881
|
+
return `<div class="${line.live ? "term-row" : "term-row term-scrollback-row"}"${line.live ? ` data-aitty-live-grid-row="${lineIndex + 1}"` : ""} data-aitty-line-cols="${lineCols}">${renderStableFrameCells(line.cells, lineCols)}</div>`;
|
|
1882
|
+
}
|
|
1883
|
+
function renderStableFrameCells(cells, lineCols) {
|
|
1884
|
+
const out = [];
|
|
1885
|
+
let usedCols = 0;
|
|
1886
|
+
let runText = "";
|
|
1887
|
+
let runWidth = 0;
|
|
1888
|
+
let runStyle = null;
|
|
1889
|
+
let runKey = null;
|
|
1890
|
+
const flush = () => {
|
|
1891
|
+
if (runText === "" && runWidth === 0) return;
|
|
1892
|
+
out.push(renderSpan(runText, runWidth, runStyle ?? DEFAULT_STYLE));
|
|
1893
|
+
runText = "";
|
|
1894
|
+
runWidth = 0;
|
|
1895
|
+
runStyle = null;
|
|
1896
|
+
runKey = null;
|
|
1897
|
+
};
|
|
1898
|
+
for (const cell of cells) {
|
|
1899
|
+
const key = styleKey(cell.style);
|
|
1900
|
+
const wide = cell.width !== 1;
|
|
1901
|
+
if (!wide && runKey === key) {
|
|
1902
|
+
runText += cell.text;
|
|
1903
|
+
runWidth += cell.width;
|
|
1904
|
+
usedCols += cell.width;
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
flush();
|
|
1908
|
+
if (wide) {
|
|
1909
|
+
out.push(renderSpan(cell.text, cell.width, cell.style, "term-wide"));
|
|
1910
|
+
usedCols += cell.width;
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
runText = cell.text;
|
|
1914
|
+
runWidth = cell.width;
|
|
1915
|
+
runStyle = cell.style;
|
|
1916
|
+
runKey = key;
|
|
1917
|
+
usedCols += cell.width;
|
|
1918
|
+
}
|
|
1919
|
+
flush();
|
|
1920
|
+
const remaining = lineCols - usedCols;
|
|
1921
|
+
if (remaining > 0) out.push(renderSpan("", remaining, DEFAULT_STYLE));
|
|
1922
|
+
return out.join("");
|
|
1923
|
+
}
|
|
1924
|
+
function renderSpan(text, width, style, className) {
|
|
1925
|
+
const declarations = styleDeclarations(style);
|
|
1926
|
+
declarations.push(widthDeclaration(width));
|
|
1927
|
+
if (className === "term-wide") declarations.push("overflow: hidden");
|
|
1928
|
+
return `<span${className ? ` class="${escapeAttribute(className)}"` : ""} style="${escapeAttribute(declarations.join("; "))}">${escapeHtml(text)}</span>`;
|
|
1929
|
+
}
|
|
1930
|
+
function styleDeclarations(style) {
|
|
1931
|
+
if (isDefaultStyle(style)) return [];
|
|
1932
|
+
const declarations = [];
|
|
1933
|
+
const fg = colorToCss(style.fg);
|
|
1934
|
+
const bg = colorToCss(style.bg);
|
|
1935
|
+
if (fg) declarations.push(`color: ${fg}`);
|
|
1936
|
+
if (bg) declarations.push(`background-color: ${bg}`);
|
|
1937
|
+
if (style.bold) declarations.push("font-weight: 700");
|
|
1938
|
+
if (style.dim) declarations.push("opacity: 0.72");
|
|
1939
|
+
if (style.italic) declarations.push("font-style: italic");
|
|
1940
|
+
const decorations = [style.underline ? "underline" : "", style.strikethrough ? "line-through" : ""].filter(Boolean);
|
|
1941
|
+
if (decorations.length > 0) declarations.push(`text-decoration: ${decorations.join(" ")}`);
|
|
1942
|
+
if (style.inverse) declarations.push("filter: invert(1)");
|
|
1943
|
+
return declarations;
|
|
1944
|
+
}
|
|
1945
|
+
function colorToCss(color) {
|
|
1946
|
+
if (color.mode === "default") return null;
|
|
1947
|
+
if (color.mode === "palette") return `var(--term-color-${color.value})`;
|
|
1948
|
+
const value = color.value;
|
|
1949
|
+
return `rgb(${value >> 16 & 255},${value >> 8 & 255},${value & 255})`;
|
|
1950
|
+
}
|
|
1951
|
+
function widthDeclaration(width) {
|
|
1952
|
+
if (width <= 1) return "width: var(--term-cell-width, 1ch)";
|
|
1953
|
+
return `width: calc(var(--term-cell-width, 1ch) * ${width})`;
|
|
1954
|
+
}
|
|
1955
|
+
function cellsDisplayWidth(cells) {
|
|
1956
|
+
return cells.reduce((sum, cell) => sum + cell.width, 0);
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1959
|
+
//#region src/agent/report_terminal_layout.ts
|
|
1960
|
+
function resolveTerminalSnapshotLayout(snapshot, viewport, viewOptions) {
|
|
1961
|
+
const cols = inferTerminalCols(snapshot);
|
|
1962
|
+
const rows = inferTerminalRows(snapshot);
|
|
1963
|
+
const mobile = isMobileViewport(viewport);
|
|
1964
|
+
return {
|
|
1965
|
+
cols,
|
|
1966
|
+
fontSize: viewOptions.fontSize,
|
|
1967
|
+
lineHeight: viewOptions.lineHeight,
|
|
1968
|
+
paddingInline: mobile ? 16 : 32,
|
|
1969
|
+
rows
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
function inferTerminalCols(snapshot) {
|
|
1973
|
+
const dataCols = /data-cols="(\d+)"/.exec(snapshot)?.[1];
|
|
1974
|
+
const styleCols = /--term-cols:\s*(\d+)/.exec(snapshot)?.[1];
|
|
1975
|
+
const parsed = Number.parseInt(dataCols ?? styleCols ?? "", 10);
|
|
1976
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 80;
|
|
1977
|
+
}
|
|
1978
|
+
function inferTerminalRows(snapshot) {
|
|
1979
|
+
const dataRows = /data-rows="(\d+)"/.exec(snapshot)?.[1];
|
|
1980
|
+
const styleRows = /--term-rows:\s*(\d+)/.exec(snapshot)?.[1];
|
|
1981
|
+
const parsed = Number.parseInt(dataRows ?? styleRows ?? "", 10);
|
|
1982
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 24;
|
|
1983
|
+
}
|
|
1984
|
+
//#endregion
|
|
1985
|
+
//#region src/agent/report_terminal_preview.ts
|
|
1986
|
+
function renderDomPreviewDocument(snapshot, viewport, viewOptions, aittyAssets) {
|
|
1987
|
+
const body = renderAittyPreviewBody({
|
|
1988
|
+
snapshot,
|
|
1989
|
+
snapshotLayout: resolveTerminalSnapshotLayout(snapshot, viewport, viewOptions),
|
|
1990
|
+
viewOptions
|
|
1991
|
+
});
|
|
1992
|
+
const style = renderAittyPreviewCss(viewOptions);
|
|
1993
|
+
const assetTags = renderAittyPreviewAssetTags(aittyAssets);
|
|
1994
|
+
return `<!doctype html>
|
|
1995
|
+
<html lang="en" data-theme="${escapeAttribute(viewOptions.theme)}">
|
|
1996
|
+
<head>
|
|
1997
|
+
<meta charset="utf-8" />
|
|
1998
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
1999
|
+
${assetTags} <style>
|
|
2000
|
+
${style}
|
|
2001
|
+
</style>
|
|
2002
|
+
</head>
|
|
2003
|
+
<body>
|
|
2004
|
+
${body}
|
|
2005
|
+
</body>
|
|
2006
|
+
</html>`;
|
|
2007
|
+
}
|
|
2008
|
+
//#endregion
|
|
2009
|
+
//#region src/agent/report_artifact_writer.ts
|
|
2010
|
+
async function writeArtifactViewerPages(result, options = {}) {
|
|
2011
|
+
const viewportsByName = new Map(result.viewports.map((viewport) => [viewport.name, viewport]));
|
|
2012
|
+
const viewOptions = resolveReportViewOptions(result, options.config);
|
|
2013
|
+
const readableArtifacts = [];
|
|
2014
|
+
for (const artifact of result.artifacts) {
|
|
2015
|
+
const viewerPath = artifactViewerPath(artifact);
|
|
2016
|
+
if (!viewerPath) continue;
|
|
2017
|
+
const content = readArtifactText(artifact.path);
|
|
2018
|
+
if (content === null) continue;
|
|
2019
|
+
readableArtifacts.push({
|
|
2020
|
+
artifact,
|
|
2021
|
+
content,
|
|
2022
|
+
viewerPath
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
const domPreviewPathsBySnapshot = new Map(readableArtifacts.filter(({ artifact }) => artifact.kind === "dom").map(({ artifact }) => [artifactSnapshotKey(artifact), artifactDomPreviewPath(artifact)]));
|
|
2026
|
+
const aittyAssets = domPreviewPathsBySnapshot.size > 0 ? prepareAittyReportAssets({
|
|
2027
|
+
artifactsDir: result.artifactsDir,
|
|
2028
|
+
flowPath: result.flowPath,
|
|
2029
|
+
reportPath: result.reportPath
|
|
2030
|
+
}) : null;
|
|
2031
|
+
const writtenDomPreviewPaths = /* @__PURE__ */ new Set();
|
|
2032
|
+
const ptyReplayStableFrame = aittyAssets ? await extractPtyReplayStableFrameForReport({
|
|
2033
|
+
config: options.config,
|
|
2034
|
+
result
|
|
2035
|
+
}) : null;
|
|
2036
|
+
if (aittyAssets) for (const { artifact, content } of readableArtifacts) {
|
|
2037
|
+
if (artifact.kind !== "dom") continue;
|
|
2038
|
+
const viewport = viewportsByName.get(artifact.viewport);
|
|
2039
|
+
const domPreviewPath = artifactDomPreviewPath(artifact);
|
|
2040
|
+
const aittyPreviewAssets = resolveAittyPreviewAssets(domPreviewPath, aittyAssets, result.artifactsDir);
|
|
2041
|
+
const previewDocument = ptyReplayStableFrame ? renderPtyReplayStableFramePreviewDocument({
|
|
2042
|
+
aittyAssets: aittyPreviewAssets,
|
|
2043
|
+
config: options.config,
|
|
2044
|
+
flowName: result.name,
|
|
2045
|
+
frame: ptyReplayStableFrame,
|
|
2046
|
+
viewOptions,
|
|
2047
|
+
viewport
|
|
2048
|
+
}) : renderDomPreviewDocument(content, viewport, viewOptions, aittyPreviewAssets);
|
|
2049
|
+
mkdirSync(dirname(domPreviewPath), { recursive: true });
|
|
2050
|
+
writeFileSync(domPreviewPath, previewDocument, "utf8");
|
|
2051
|
+
writtenDomPreviewPaths.add(domPreviewPath);
|
|
2052
|
+
}
|
|
2053
|
+
for (const { artifact, content, viewerPath } of readableArtifacts) {
|
|
2054
|
+
const viewport = viewportsByName.get(artifact.viewport);
|
|
2055
|
+
const domPreviewPath = artifact.kind === "dom" ? artifactDomPreviewPath(artifact) : null;
|
|
2056
|
+
mkdirSync(dirname(viewerPath), { recursive: true });
|
|
2057
|
+
writeFileSync(viewerPath, renderArtifactViewerHtml({
|
|
2058
|
+
artifact,
|
|
2059
|
+
artifactsDir: result.artifactsDir,
|
|
2060
|
+
content,
|
|
2061
|
+
reportPath: result.reportPath,
|
|
2062
|
+
viewerPath,
|
|
2063
|
+
domPreview: resolveReportDomPreview(domPreviewPath && writtenDomPreviewPaths.has(domPreviewPath) ? domPreviewPath : null),
|
|
2064
|
+
viewOptions,
|
|
2065
|
+
viewport,
|
|
2066
|
+
terminalDomPreview: artifact.kind === "terminal" ? resolveReportDomPreview(resolveWrittenDomPreviewPath(domPreviewPathsBySnapshot, writtenDomPreviewPaths, artifact)) : null
|
|
2067
|
+
}), "utf8");
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
function resolveWrittenDomPreviewPath(domPreviewPathsBySnapshot, writtenDomPreviewPaths, artifact) {
|
|
2071
|
+
const path = domPreviewPathsBySnapshot.get(artifactSnapshotKey(artifact));
|
|
2072
|
+
return path && writtenDomPreviewPaths.has(path) ? path : null;
|
|
2073
|
+
}
|
|
2074
|
+
function readArtifactText(path) {
|
|
2075
|
+
try {
|
|
2076
|
+
return readFileSync(path, "utf8");
|
|
2077
|
+
} catch {
|
|
2078
|
+
return null;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
//#endregion
|
|
2082
|
+
//#region src/agent/report_index_artifacts.ts
|
|
2083
|
+
function renderAgentReportArtifacts(args) {
|
|
2084
|
+
return args.artifacts.map((artifact) => renderArtifactRow({
|
|
2085
|
+
artifact,
|
|
2086
|
+
artifactsDir: args.artifactsDir,
|
|
2087
|
+
reportPath: args.reportPath
|
|
2088
|
+
})).join("\n") || "<p>No artifacts were captured.</p>";
|
|
2089
|
+
}
|
|
2090
|
+
function renderArtifactRow(args) {
|
|
2091
|
+
const { artifact, artifactsDir, reportPath } = args;
|
|
2092
|
+
const state = artifact.ok ? "pass" : "fail";
|
|
2093
|
+
const href = relativeHref(reportPath, artifact.path, artifactsDir);
|
|
2094
|
+
const baselineHref = artifact.baselinePath ? relativeHref(reportPath, artifact.baselinePath, artifactsDir) : "";
|
|
2095
|
+
const diffHref = artifact.diffPath ? relativeHref(reportPath, artifact.diffPath, artifactsDir) : "";
|
|
2096
|
+
const viewerPath = artifactViewerPath(artifact);
|
|
2097
|
+
const viewerHref = viewerPath ? relativeHref(reportPath, viewerPath, artifactsDir) : "";
|
|
2098
|
+
return `<article class="artifact">
|
|
2099
|
+
<div class="artifact-summary">
|
|
2100
|
+
<span class="badge ${state}">${state}</span>
|
|
2101
|
+
<div class="artifact-meta">
|
|
2102
|
+
<div class="artifact-links">
|
|
2103
|
+
<a href="${escapeAttribute(viewerHref || href)}">${escapeHtml(artifact.kind)}</a>
|
|
2104
|
+
${viewerHref ? `<a href="${escapeAttribute(href)}">raw</a>` : ""}
|
|
2105
|
+
${baselineHref ? `<a href="${escapeAttribute(baselineHref)}">baseline</a>` : ""}
|
|
2106
|
+
${diffHref ? `<a href="${escapeAttribute(diffHref)}">diff</a>` : ""}
|
|
2107
|
+
</div>
|
|
2108
|
+
<div><code>${escapeHtml(artifact.viewport)} / ${escapeHtml(artifact.name)}</code></div>
|
|
2109
|
+
${artifact.error ? `<div><code>${escapeHtml(artifact.error)}</code></div>` : ""}
|
|
2110
|
+
</div>
|
|
2111
|
+
<code class="artifact-hash">${escapeHtml(artifact.hash ?? "")}</code>
|
|
2112
|
+
</div>
|
|
2113
|
+
</article>`;
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/agent/report_index_commands.ts
|
|
2117
|
+
function renderAgentReportCommandBlock(label, argv) {
|
|
2118
|
+
return `<div class="command">
|
|
2119
|
+
<span>${escapeHtml(label)}</span>
|
|
2120
|
+
<pre>${escapeHtml(formatAgentArgv(argv))}</pre>
|
|
2121
|
+
</div>`;
|
|
2122
|
+
}
|
|
2123
|
+
//#endregion
|
|
2124
|
+
//#region src/agent/report_index_artifacts_css.ts
|
|
2125
|
+
function renderAgentReportArtifactsCss() {
|
|
2126
|
+
return ` .artifacts {
|
|
1130
2127
|
display: grid;
|
|
1131
|
-
gap:
|
|
1132
|
-
border: 1px solid var(--line);
|
|
1133
|
-
border-radius: 8px;
|
|
1134
|
-
background: var(--panel);
|
|
1135
|
-
padding: 18px;
|
|
2128
|
+
gap: 14px;
|
|
1136
2129
|
}
|
|
1137
|
-
.
|
|
2130
|
+
.artifact {
|
|
1138
2131
|
display: grid;
|
|
1139
|
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
1140
2132
|
gap: 12px;
|
|
1141
|
-
}
|
|
1142
|
-
.metric {
|
|
1143
2133
|
border: 1px solid var(--line);
|
|
1144
2134
|
border-radius: 8px;
|
|
1145
|
-
padding:
|
|
1146
|
-
background:
|
|
1147
|
-
}
|
|
1148
|
-
.metric strong {
|
|
1149
|
-
display: block;
|
|
1150
|
-
font-size: 24px;
|
|
1151
|
-
line-height: 1.1;
|
|
1152
|
-
}
|
|
1153
|
-
.metric span {
|
|
1154
|
-
display: block;
|
|
1155
|
-
margin-top: 4px;
|
|
1156
|
-
color: var(--muted);
|
|
1157
|
-
font-size: 13px;
|
|
2135
|
+
padding: 12px;
|
|
2136
|
+
background: var(--panel);
|
|
1158
2137
|
}
|
|
1159
|
-
.
|
|
2138
|
+
.artifact-summary {
|
|
1160
2139
|
display: grid;
|
|
1161
|
-
|
|
2140
|
+
grid-template-columns: auto minmax(0, 1fr) minmax(96px, auto);
|
|
2141
|
+
gap: 14px;
|
|
2142
|
+
align-items: start;
|
|
1162
2143
|
}
|
|
1163
|
-
.artifact {
|
|
2144
|
+
.artifact-meta {
|
|
1164
2145
|
display: grid;
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
2146
|
+
gap: 4px;
|
|
2147
|
+
min-width: 0;
|
|
2148
|
+
}
|
|
2149
|
+
.artifact-links {
|
|
2150
|
+
display: flex;
|
|
2151
|
+
flex-wrap: wrap;
|
|
2152
|
+
gap: 8px 12px;
|
|
1172
2153
|
}
|
|
1173
2154
|
.artifact a {
|
|
1174
2155
|
color: var(--focus);
|
|
1175
2156
|
font-weight: 700;
|
|
1176
2157
|
text-decoration: none;
|
|
1177
2158
|
}
|
|
1178
|
-
.artifact code,
|
|
1179
|
-
pre {
|
|
1180
|
-
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
|
1181
|
-
}
|
|
1182
2159
|
.artifact code {
|
|
1183
2160
|
color: var(--muted);
|
|
1184
2161
|
overflow-wrap: anywhere;
|
|
1185
2162
|
}
|
|
1186
|
-
.
|
|
1187
|
-
justify-self:
|
|
2163
|
+
.artifact-hash {
|
|
2164
|
+
justify-self: end;
|
|
2165
|
+
text-align: right;
|
|
2166
|
+
}
|
|
2167
|
+
.badge {
|
|
2168
|
+
justify-self: start;
|
|
2169
|
+
border-radius: 999px;
|
|
2170
|
+
padding: 5px 9px;
|
|
2171
|
+
background: color-mix(in oklch, var(--line) 52%, transparent);
|
|
2172
|
+
color: var(--muted);
|
|
2173
|
+
font-size: 12px;
|
|
2174
|
+
font-weight: 700;
|
|
2175
|
+
}
|
|
2176
|
+
.badge.fail {
|
|
2177
|
+
background: color-mix(in oklch, var(--bad) 12%, var(--panel));
|
|
2178
|
+
color: var(--bad);
|
|
2179
|
+
}
|
|
2180
|
+
.badge.pass {
|
|
2181
|
+
background: color-mix(in oklch, var(--good) 12%, var(--panel));
|
|
2182
|
+
color: var(--good);
|
|
2183
|
+
}
|
|
2184
|
+
@media (max-width: 720px) {
|
|
2185
|
+
.artifact-summary {
|
|
2186
|
+
grid-template-columns: 1fr;
|
|
2187
|
+
}
|
|
2188
|
+
.artifact-hash {
|
|
2189
|
+
justify-self: start;
|
|
2190
|
+
text-align: left;
|
|
2191
|
+
}
|
|
2192
|
+
}`;
|
|
2193
|
+
}
|
|
2194
|
+
//#endregion
|
|
2195
|
+
//#region src/agent/report_index_base_css.ts
|
|
2196
|
+
function renderAgentReportBaseCss() {
|
|
2197
|
+
return ` :root {
|
|
2198
|
+
color-scheme: light;
|
|
2199
|
+
--bg: oklch(97.5% 0.008 210);
|
|
2200
|
+
--ink: oklch(19% 0.018 230);
|
|
2201
|
+
--muted: oklch(48% 0.02 230);
|
|
2202
|
+
--line: oklch(86% 0.018 230);
|
|
2203
|
+
--panel: oklch(99% 0.006 210);
|
|
2204
|
+
--good: oklch(55% 0.15 155);
|
|
2205
|
+
--bad: oklch(58% 0.19 25);
|
|
2206
|
+
--focus: oklch(55% 0.14 235);
|
|
2207
|
+
font-family:
|
|
2208
|
+
ui-sans-serif,
|
|
2209
|
+
system-ui,
|
|
2210
|
+
-apple-system,
|
|
2211
|
+
BlinkMacSystemFont,
|
|
2212
|
+
"Segoe UI",
|
|
2213
|
+
sans-serif;
|
|
2214
|
+
}
|
|
2215
|
+
* { box-sizing: border-box; }
|
|
2216
|
+
body {
|
|
2217
|
+
margin: 0;
|
|
2218
|
+
background: var(--bg);
|
|
2219
|
+
color: var(--ink);
|
|
2220
|
+
}
|
|
2221
|
+
main {
|
|
2222
|
+
display: grid;
|
|
2223
|
+
gap: 24px;
|
|
2224
|
+
width: min(1180px, calc(100vw - 32px));
|
|
2225
|
+
margin: 0 auto;
|
|
2226
|
+
padding: 32px 0 48px;
|
|
2227
|
+
}
|
|
2228
|
+
header {
|
|
2229
|
+
display: grid;
|
|
2230
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2231
|
+
gap: 20px;
|
|
2232
|
+
align-items: start;
|
|
2233
|
+
border-bottom: 1px solid var(--line);
|
|
2234
|
+
padding-bottom: 24px;
|
|
2235
|
+
}
|
|
2236
|
+
h1 {
|
|
2237
|
+
margin: 0;
|
|
2238
|
+
font-size: 28px;
|
|
2239
|
+
line-height: 1.15;
|
|
2240
|
+
letter-spacing: 0;
|
|
2241
|
+
}
|
|
2242
|
+
h2 {
|
|
2243
|
+
margin: 0;
|
|
2244
|
+
font-size: 18px;
|
|
2245
|
+
line-height: 1.25;
|
|
2246
|
+
}
|
|
2247
|
+
.meta {
|
|
2248
|
+
display: flex;
|
|
2249
|
+
flex-wrap: wrap;
|
|
2250
|
+
gap: 8px;
|
|
2251
|
+
margin-top: 12px;
|
|
2252
|
+
}
|
|
2253
|
+
.pill,
|
|
2254
|
+
.status {
|
|
2255
|
+
display: inline-flex;
|
|
2256
|
+
min-height: 32px;
|
|
2257
|
+
align-items: center;
|
|
2258
|
+
border: 1px solid var(--line);
|
|
1188
2259
|
border-radius: 999px;
|
|
1189
|
-
padding:
|
|
1190
|
-
background: color-mix(in oklch, var(--line) 52%, transparent);
|
|
2260
|
+
padding: 0 12px;
|
|
1191
2261
|
color: var(--muted);
|
|
1192
|
-
font-size:
|
|
2262
|
+
font-size: 13px;
|
|
2263
|
+
}
|
|
2264
|
+
.status {
|
|
1193
2265
|
font-weight: 700;
|
|
1194
2266
|
}
|
|
1195
|
-
.
|
|
1196
|
-
|
|
2267
|
+
.status.pass {
|
|
2268
|
+
border-color: color-mix(in oklch, var(--good) 42%, var(--line));
|
|
2269
|
+
color: var(--good);
|
|
2270
|
+
}
|
|
2271
|
+
.status.fail {
|
|
2272
|
+
border-color: color-mix(in oklch, var(--bad) 44%, var(--line));
|
|
1197
2273
|
color: var(--bad);
|
|
1198
2274
|
}
|
|
1199
|
-
.
|
|
1200
|
-
|
|
1201
|
-
|
|
2275
|
+
.panel {
|
|
2276
|
+
display: grid;
|
|
2277
|
+
gap: 16px;
|
|
2278
|
+
border: 1px solid var(--line);
|
|
2279
|
+
border-radius: 8px;
|
|
2280
|
+
background: var(--panel);
|
|
2281
|
+
padding: 18px;
|
|
1202
2282
|
}
|
|
1203
|
-
|
|
2283
|
+
@media (max-width: 720px) {
|
|
2284
|
+
main {
|
|
2285
|
+
width: min(100vw - 20px, 1180px);
|
|
2286
|
+
padding-top: 18px;
|
|
2287
|
+
}
|
|
2288
|
+
header {
|
|
2289
|
+
grid-template-columns: 1fr;
|
|
2290
|
+
}
|
|
2291
|
+
.status {
|
|
2292
|
+
justify-self: start;
|
|
2293
|
+
}
|
|
2294
|
+
}`;
|
|
2295
|
+
}
|
|
2296
|
+
//#endregion
|
|
2297
|
+
//#region src/agent/report_index_commands_css.ts
|
|
2298
|
+
function renderAgentReportCommandsCss() {
|
|
2299
|
+
return ` .commands {
|
|
1204
2300
|
display: grid;
|
|
1205
2301
|
gap: 10px;
|
|
1206
2302
|
}
|
|
@@ -1213,6 +2309,10 @@ function renderAgentReportHtml(result) {
|
|
|
1213
2309
|
font-size: 13px;
|
|
1214
2310
|
font-weight: 700;
|
|
1215
2311
|
}
|
|
2312
|
+
.artifact code,
|
|
2313
|
+
pre {
|
|
2314
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
|
2315
|
+
}
|
|
1216
2316
|
pre {
|
|
1217
2317
|
overflow: auto;
|
|
1218
2318
|
margin: 0;
|
|
@@ -1221,20 +2321,63 @@ function renderAgentReportHtml(result) {
|
|
|
1221
2321
|
color: oklch(92% 0.012 230);
|
|
1222
2322
|
padding: 14px;
|
|
1223
2323
|
line-height: 1.5;
|
|
2324
|
+
}`;
|
|
2325
|
+
}
|
|
2326
|
+
//#endregion
|
|
2327
|
+
//#region src/agent/report_index_summary_css.ts
|
|
2328
|
+
function renderAgentReportSummaryCss() {
|
|
2329
|
+
return ` .summary {
|
|
2330
|
+
display: grid;
|
|
2331
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2332
|
+
gap: 12px;
|
|
1224
2333
|
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
.
|
|
1235
|
-
justify-self: start;
|
|
1236
|
-
}
|
|
2334
|
+
.metric {
|
|
2335
|
+
border: 1px solid var(--line);
|
|
2336
|
+
border-radius: 8px;
|
|
2337
|
+
padding: 14px;
|
|
2338
|
+
background: color-mix(in oklch, var(--panel) 82%, var(--bg));
|
|
2339
|
+
}
|
|
2340
|
+
.metric strong {
|
|
2341
|
+
display: block;
|
|
2342
|
+
font-size: 24px;
|
|
2343
|
+
line-height: 1.1;
|
|
1237
2344
|
}
|
|
2345
|
+
.metric span {
|
|
2346
|
+
display: block;
|
|
2347
|
+
margin-top: 4px;
|
|
2348
|
+
color: var(--muted);
|
|
2349
|
+
font-size: 13px;
|
|
2350
|
+
}`;
|
|
2351
|
+
}
|
|
2352
|
+
//#endregion
|
|
2353
|
+
//#region src/agent/report_index_css.ts
|
|
2354
|
+
function renderAgentReportCss() {
|
|
2355
|
+
return [
|
|
2356
|
+
renderAgentReportBaseCss(),
|
|
2357
|
+
renderAgentReportSummaryCss(),
|
|
2358
|
+
renderAgentReportArtifactsCss(),
|
|
2359
|
+
renderAgentReportCommandsCss()
|
|
2360
|
+
].join("\n");
|
|
2361
|
+
}
|
|
2362
|
+
//#endregion
|
|
2363
|
+
//#region src/agent/report_index.ts
|
|
2364
|
+
function renderAgentReportHtml(result) {
|
|
2365
|
+
const artifacts = renderAgentReportArtifacts({
|
|
2366
|
+
artifacts: result.artifacts,
|
|
2367
|
+
artifactsDir: result.artifactsDir,
|
|
2368
|
+
reportPath: result.reportPath
|
|
2369
|
+
});
|
|
2370
|
+
const viewportTabs = result.viewports.map((viewport) => `<span class="pill">${escapeHtml(viewport.name)} ${viewport.width}x${viewport.height}</span>`).join("");
|
|
2371
|
+
const status = result.ok ? "passed" : "failed";
|
|
2372
|
+
const statusClass = result.ok ? "pass" : "fail";
|
|
2373
|
+
return `<!doctype html>
|
|
2374
|
+
<html lang="en">
|
|
2375
|
+
<head>
|
|
2376
|
+
<meta charset="utf-8" />
|
|
2377
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2378
|
+
<title>${escapeHtml(`${result.name} terminal-agent report`)}</title>
|
|
2379
|
+
<style>
|
|
2380
|
+
${renderAgentReportCss()}
|
|
1238
2381
|
</style>
|
|
1239
2382
|
</head>
|
|
1240
2383
|
<body>
|
|
@@ -1243,7 +2386,7 @@ function renderAgentReportHtml(result) {
|
|
|
1243
2386
|
<div>
|
|
1244
2387
|
<h1>${escapeHtml(result.name)}</h1>
|
|
1245
2388
|
<div class="meta">
|
|
1246
|
-
<span class="status">${status}</span>
|
|
2389
|
+
<span class="status ${statusClass}">${status}</span>
|
|
1247
2390
|
<span class="pill">${escapeHtml(result.mode)}</span>
|
|
1248
2391
|
<span class="pill">${escapeHtml(result.agentFlavor)}</span>
|
|
1249
2392
|
${viewportTabs}
|
|
@@ -1262,9 +2405,9 @@ function renderAgentReportHtml(result) {
|
|
|
1262
2405
|
<section class="panel">
|
|
1263
2406
|
<h2>Commands</h2>
|
|
1264
2407
|
<div class="commands">
|
|
1265
|
-
${
|
|
1266
|
-
${
|
|
1267
|
-
${
|
|
2408
|
+
${renderAgentReportCommandBlock("replay", result.commands.replay.argv)}
|
|
2409
|
+
${renderAgentReportCommandBlock("update snapshots", result.commands.updateSnapshots.argv)}
|
|
2410
|
+
${renderAgentReportCommandBlock("inspect commands", [
|
|
1268
2411
|
"ptywright",
|
|
1269
2412
|
"agent",
|
|
1270
2413
|
"commands",
|
|
@@ -1278,93 +2421,263 @@ function renderAgentReportHtml(result) {
|
|
|
1278
2421
|
<section class="panel">
|
|
1279
2422
|
<h2>Terminal Agent Artifacts</h2>
|
|
1280
2423
|
<div class="artifacts">
|
|
1281
|
-
${artifacts
|
|
2424
|
+
${artifacts}
|
|
1282
2425
|
</div>
|
|
1283
2426
|
</section>
|
|
1284
2427
|
</main>
|
|
1285
2428
|
</body>
|
|
1286
2429
|
</html>`;
|
|
1287
2430
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
2431
|
+
//#endregion
|
|
2432
|
+
//#region src/agent/report.ts
|
|
2433
|
+
async function writeAgentReport(path, result, options = {}) {
|
|
2434
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2435
|
+
await writeArtifactViewerPages(result, options);
|
|
2436
|
+
writeFileSync(path, renderAgentReportHtml(result), "utf8");
|
|
1293
2437
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2438
|
+
//#endregion
|
|
2439
|
+
//#region src/agent/run_artifacts.ts
|
|
2440
|
+
function writeRunRecord(result, spec) {
|
|
2441
|
+
const record = {
|
|
2442
|
+
$schema: AGENT_RUN_RECORD_SCHEMA_URL,
|
|
2443
|
+
version: 1,
|
|
2444
|
+
name: result.name,
|
|
2445
|
+
ok: result.ok,
|
|
2446
|
+
startedAt: new Date(result.startedAt).toISOString(),
|
|
2447
|
+
durationMs: result.durationMs,
|
|
2448
|
+
mode: result.mode,
|
|
2449
|
+
spec,
|
|
2450
|
+
flowPath: relative(dirname(result.recordPath), result.flowPath),
|
|
2451
|
+
artifactsDir: result.artifactsDir,
|
|
2452
|
+
snapshotDir: result.snapshotDir,
|
|
2453
|
+
reportPath: result.reportPath,
|
|
2454
|
+
cassettePath: relative(dirname(result.recordPath), result.cassettePath),
|
|
2455
|
+
cassetteFrameCount: result.cassetteFrameCount,
|
|
2456
|
+
replayCommand: result.replayCommand,
|
|
2457
|
+
commands: result.commands,
|
|
2458
|
+
steps: result.steps,
|
|
2459
|
+
artifacts: result.artifacts,
|
|
2460
|
+
errors: result.errors
|
|
2461
|
+
};
|
|
2462
|
+
writeAgentRunRecordPath(result.recordPath, record);
|
|
2463
|
+
}
|
|
2464
|
+
function writeRunManifest(result) {
|
|
2465
|
+
writeAgentManifestPath(agentManifestPath(result.artifactsDir), {
|
|
2466
|
+
kind: "run",
|
|
2467
|
+
ok: result.ok,
|
|
2468
|
+
rootDir: result.artifactsDir,
|
|
2469
|
+
primaryPath: result.recordPath,
|
|
2470
|
+
commands: result.commands,
|
|
2471
|
+
validation: {
|
|
2472
|
+
ok: result.ok,
|
|
2473
|
+
stages: [{
|
|
2474
|
+
name: "run",
|
|
2475
|
+
ok: result.ok,
|
|
2476
|
+
totalCount: result.artifacts.length,
|
|
2477
|
+
failureCount: result.artifacts.filter((artifact) => !artifact.ok).length
|
|
2478
|
+
}]
|
|
2479
|
+
},
|
|
2480
|
+
files: [
|
|
2481
|
+
{
|
|
2482
|
+
path: result.flowPath,
|
|
2483
|
+
kind: "flow",
|
|
2484
|
+
role: "flow"
|
|
2485
|
+
},
|
|
2486
|
+
{
|
|
2487
|
+
path: result.cassettePath,
|
|
2488
|
+
kind: "cassette",
|
|
2489
|
+
role: "cassette"
|
|
2490
|
+
},
|
|
2491
|
+
{
|
|
2492
|
+
path: result.recordPath,
|
|
2493
|
+
kind: "run-record",
|
|
2494
|
+
role: "record",
|
|
2495
|
+
ok: result.ok
|
|
2496
|
+
},
|
|
2497
|
+
{
|
|
2498
|
+
path: result.reportPath,
|
|
2499
|
+
kind: "report",
|
|
2500
|
+
role: "report",
|
|
2501
|
+
ok: result.ok
|
|
2502
|
+
},
|
|
2503
|
+
...result.artifacts.flatMap((artifact) => [{
|
|
2504
|
+
path: artifact.path,
|
|
2505
|
+
kind: artifact.kind,
|
|
2506
|
+
role: "artifact",
|
|
2507
|
+
ok: artifact.ok
|
|
2508
|
+
}, {
|
|
2509
|
+
path: artifact.diffPath,
|
|
2510
|
+
kind: "diff",
|
|
2511
|
+
role: "diff",
|
|
2512
|
+
ok: artifact.ok
|
|
2513
|
+
}])
|
|
2514
|
+
]
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
function writeFlowArtifact(path, spec) {
|
|
2518
|
+
writeFileSync(path, JSON.stringify(spec, null, 2) + "\n", "utf8");
|
|
2519
|
+
}
|
|
2520
|
+
//#endregion
|
|
2521
|
+
//#region src/agent/config_defaults.ts
|
|
2522
|
+
function normalizeAgentFlowSpecWithConfig(input, config) {
|
|
2523
|
+
return normalizeAgentFlowSpec(applyAgentConfigDefaults(agentFlowSpecSchema.parse(input), config));
|
|
2524
|
+
}
|
|
2525
|
+
function applyAgentConfigDefaults(input, config) {
|
|
2526
|
+
const agent = config?.agent;
|
|
2527
|
+
if (!agent) return input;
|
|
2528
|
+
const name = sanitizeArtifactName(input.name ?? "agent-flow");
|
|
2529
|
+
const configDefaults = agent.defaults ?? {};
|
|
2530
|
+
const specDefaults = input.defaults ?? {};
|
|
2531
|
+
const viewports = input.viewports ? void 0 : cloneViewports(configDefaults.viewports);
|
|
2532
|
+
return {
|
|
2533
|
+
...input,
|
|
2534
|
+
artifactsDir: input.artifactsDir ?? resolveNamedDir(agent.artifactsRoot, name, config.rootDir),
|
|
2535
|
+
snapshotDir: input.snapshotDir ?? resolveNamedDir(agent.snapshotDir, name, config.rootDir),
|
|
2536
|
+
viewports: viewports ?? input.viewports,
|
|
2537
|
+
defaults: {
|
|
2538
|
+
...specDefaults,
|
|
2539
|
+
timeoutMs: specDefaults.timeoutMs ?? configDefaults.timeoutMs,
|
|
2540
|
+
screenshot: specDefaults.screenshot ?? configDefaults.screenshot,
|
|
2541
|
+
mask: mergeMaskRules(configDefaults.mask, specDefaults.mask)
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
function resolveNamedDir(root, name, configRoot) {
|
|
2546
|
+
if (!root) return void 0;
|
|
2547
|
+
const namedDir = join(root, name);
|
|
2548
|
+
return isAbsolute(namedDir) ? namedDir : resolve(configRoot, namedDir);
|
|
2549
|
+
}
|
|
2550
|
+
function cloneViewports(viewports) {
|
|
2551
|
+
return Array.isArray(viewports) && viewports.length > 0 ? viewports.map((viewport) => ({ ...viewport })) : void 0;
|
|
2552
|
+
}
|
|
2553
|
+
function mergeMaskRules(configMask, specMask) {
|
|
2554
|
+
const merged = [...configMask ?? [], ...specMask ?? []];
|
|
2555
|
+
return merged.length > 0 ? merged : void 0;
|
|
2556
|
+
}
|
|
2557
|
+
//#endregion
|
|
2558
|
+
//#region src/agent/command_launch.ts
|
|
2559
|
+
const DEFAULT_URL_REGEX = /https?:\/\/[^\s"'<>]+/;
|
|
2560
|
+
function buildCommandLaunchCommand(launch, options = {}) {
|
|
2561
|
+
if (!launch.command) throw new Error("launch.command is required when launch.mode is 'command'");
|
|
2562
|
+
const rootDir = options.rootDir ?? process.cwd();
|
|
2563
|
+
const cwd = launch.cwd ? resolve(rootDir, launch.cwd) : rootDir;
|
|
2564
|
+
return {
|
|
2565
|
+
file: launch.command,
|
|
2566
|
+
args: launch.args ?? [],
|
|
2567
|
+
cwd,
|
|
2568
|
+
env: {
|
|
2569
|
+
...options.env ?? process.env,
|
|
2570
|
+
...launch.env
|
|
2571
|
+
},
|
|
2572
|
+
label: launch.command,
|
|
2573
|
+
urlRegex: launch.urlRegex,
|
|
2574
|
+
waitForUrlMs: launch.waitForUrlMs
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
async function launchBrowserSessionFromCommand(command) {
|
|
2578
|
+
const timeoutMs = command.waitForUrlMs ?? 15e3;
|
|
2579
|
+
const child = spawn(command.file, command.args, {
|
|
2580
|
+
cwd: command.cwd,
|
|
2581
|
+
env: command.env,
|
|
2582
|
+
stdio: [
|
|
2583
|
+
"ignore",
|
|
2584
|
+
"pipe",
|
|
2585
|
+
"pipe"
|
|
2586
|
+
]
|
|
2587
|
+
});
|
|
2588
|
+
const stdoutChunks = [];
|
|
2589
|
+
const stderrChunks = [];
|
|
2590
|
+
return {
|
|
2591
|
+
url: await new Promise((resolveUrl, reject) => {
|
|
2592
|
+
let settled = false;
|
|
2593
|
+
const timer = setTimeout(() => {
|
|
2594
|
+
finish(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for ${command.label ?? command.file} session URL\nstdout=${stdoutChunks.join("").trim()}\nstderr=${stderrChunks.join("").trim()}`));
|
|
2595
|
+
}, timeoutMs);
|
|
2596
|
+
const finish = (result) => {
|
|
2597
|
+
if (settled) return;
|
|
2598
|
+
settled = true;
|
|
2599
|
+
clearTimeout(timer);
|
|
2600
|
+
child.stdout.off("data", onStdout);
|
|
2601
|
+
child.stderr.off("data", onStderr);
|
|
2602
|
+
child.off("error", onError);
|
|
2603
|
+
child.off("exit", onExit);
|
|
2604
|
+
if (result instanceof Error) reject(result);
|
|
2605
|
+
else resolveUrl(result);
|
|
2606
|
+
};
|
|
2607
|
+
const readUrl = () => {
|
|
2608
|
+
const found = extractUrlFromOutput(`${stdoutChunks.join("")}\n${stderrChunks.join("")}`, command.urlRegex);
|
|
2609
|
+
if (found) finish(found);
|
|
2610
|
+
};
|
|
2611
|
+
const onStdout = (chunk) => {
|
|
2612
|
+
stdoutChunks.push(chunk.toString("utf8"));
|
|
2613
|
+
readUrl();
|
|
2614
|
+
};
|
|
2615
|
+
const onStderr = (chunk) => {
|
|
2616
|
+
stderrChunks.push(chunk.toString("utf8"));
|
|
2617
|
+
readUrl();
|
|
2618
|
+
};
|
|
2619
|
+
const onError = (error) => finish(error);
|
|
2620
|
+
const onExit = (code, signal) => {
|
|
2621
|
+
finish(/* @__PURE__ */ new Error(`${command.label ?? command.file} exited before printing a session URL (code=${code ?? "null"} signal=${signal ?? "null"})\nstdout=${stdoutChunks.join("").trim()}\nstderr=${stderrChunks.join("").trim()}`));
|
|
2622
|
+
};
|
|
2623
|
+
child.stdout.on("data", onStdout);
|
|
2624
|
+
child.stderr.on("data", onStderr);
|
|
2625
|
+
child.once("error", onError);
|
|
2626
|
+
child.once("exit", onExit);
|
|
2627
|
+
}),
|
|
2628
|
+
process: child,
|
|
2629
|
+
close: () => closeChild(child)
|
|
2630
|
+
};
|
|
1310
2631
|
}
|
|
1311
|
-
function
|
|
1312
|
-
if (
|
|
1313
|
-
|
|
2632
|
+
function extractUrlFromOutput(output, regexSource) {
|
|
2633
|
+
if (!regexSource) return output.match(DEFAULT_URL_REGEX)?.[0] ?? null;
|
|
2634
|
+
const match = output.match(new RegExp(regexSource, "m"));
|
|
2635
|
+
return match?.[1] ?? match?.[0] ?? null;
|
|
1314
2636
|
}
|
|
1315
|
-
function
|
|
1316
|
-
return
|
|
2637
|
+
function formatBrowserLaunchCommand(command) {
|
|
2638
|
+
return [command.file, ...command.args].join(" ");
|
|
1317
2639
|
}
|
|
1318
|
-
function
|
|
1319
|
-
|
|
2640
|
+
async function closeChild(child) {
|
|
2641
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
2642
|
+
await new Promise((resolveClose) => {
|
|
2643
|
+
const timer = setTimeout(() => {
|
|
2644
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
2645
|
+
resolveClose();
|
|
2646
|
+
}, 2e3);
|
|
2647
|
+
child.once("exit", () => {
|
|
2648
|
+
clearTimeout(timer);
|
|
2649
|
+
resolveClose();
|
|
2650
|
+
});
|
|
2651
|
+
child.kill("SIGTERM");
|
|
2652
|
+
});
|
|
1320
2653
|
}
|
|
1321
2654
|
//#endregion
|
|
1322
|
-
//#region src/agent/
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const
|
|
2655
|
+
//#region src/agent/launch.ts
|
|
2656
|
+
function buildAgentLaunchCommand(launch, options = {}) {
|
|
2657
|
+
if (resolveAgentLaunchMode(launch) === "url") return null;
|
|
2658
|
+
return buildCommandLaunchCommand(launch, options);
|
|
2659
|
+
}
|
|
2660
|
+
async function resolveAgentLaunchTarget(launch, options = {}) {
|
|
2661
|
+
const mode = resolveAgentLaunchMode(launch);
|
|
2662
|
+
if (mode === "url") return {
|
|
2663
|
+
mode,
|
|
2664
|
+
url: launch.url,
|
|
2665
|
+
session: null
|
|
2666
|
+
};
|
|
2667
|
+
const session = await launchBrowserSessionFromCommand(buildCommandLaunchCommand(launch, options));
|
|
1335
2668
|
return {
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
2669
|
+
mode,
|
|
2670
|
+
url: session.url,
|
|
2671
|
+
session
|
|
1339
2672
|
};
|
|
1340
2673
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
return runAgentSpec((await loadAgentSpec(specPath)).raw, options);
|
|
1345
|
-
}
|
|
1346
|
-
async function replayAgentRecordPath(recordPath, options = {}) {
|
|
1347
|
-
const raw = JSON.parse(readFileSync(recordPath, "utf8"));
|
|
1348
|
-
if (isAgentCassetteLike(raw)) return replayAgentCassette(normalizeAgentCassette(raw), recordPath, options);
|
|
1349
|
-
const record = readAgentRunRecordPath(recordPath);
|
|
1350
|
-
if (record.cassettePath) {
|
|
1351
|
-
const cassettePath = isAbsolute(record.cassettePath) ? record.cassettePath : resolve(dirname(recordPath), record.cassettePath);
|
|
1352
|
-
return replayAgentCassette(readAgentCassettePath(cassettePath, record.spec), cassettePath, {
|
|
1353
|
-
...options,
|
|
1354
|
-
artifactsDir: options.artifactsDir ?? join(dirname(recordPath), "replay")
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
if (record.spec) return runAgentSpec(record.spec, {
|
|
1358
|
-
...options,
|
|
1359
|
-
config: void 0
|
|
1360
|
-
});
|
|
1361
|
-
if (!record.flowPath) throw new Error(`invalid agent run record: missing replay source in ${recordPath}`);
|
|
1362
|
-
return runAgentSpecPath(isAbsolute(record.flowPath) ? record.flowPath : resolve(dirname(recordPath), record.flowPath), {
|
|
1363
|
-
...options,
|
|
1364
|
-
config: void 0
|
|
1365
|
-
});
|
|
2674
|
+
function formatAgentLaunchCommand(launch) {
|
|
2675
|
+
const command = buildAgentLaunchCommand(launch);
|
|
2676
|
+
return command ? formatBrowserLaunchCommand(command) : "launch.mode=url";
|
|
1366
2677
|
}
|
|
1367
|
-
|
|
2678
|
+
//#endregion
|
|
2679
|
+
//#region src/agent/runner_setup.ts
|
|
2680
|
+
function prepareAgentRun(input, options) {
|
|
1368
2681
|
const startedAt = Date.now();
|
|
1369
2682
|
const rootDir = options.rootDir ? resolve(process.cwd(), options.rootDir) : process.cwd();
|
|
1370
2683
|
const spec = normalizeAgentFlowSpecWithConfig(input, options.replayCassette ? void 0 : options.config);
|
|
@@ -1385,161 +2698,51 @@ async function runAgentSpec(input, options = {}) {
|
|
|
1385
2698
|
"replay",
|
|
1386
2699
|
relative(process.cwd(), recordPath)
|
|
1387
2700
|
];
|
|
1388
|
-
|
|
1389
|
-
ok: true,
|
|
1390
|
-
name,
|
|
1391
|
-
mode: options.replayCassette ? "replay" : "live",
|
|
2701
|
+
return {
|
|
1392
2702
|
startedAt,
|
|
1393
|
-
|
|
2703
|
+
rootDir,
|
|
2704
|
+
spec,
|
|
2705
|
+
name,
|
|
1394
2706
|
artifactsDir,
|
|
1395
2707
|
snapshotDir,
|
|
1396
2708
|
reportPath,
|
|
1397
2709
|
recordPath,
|
|
1398
2710
|
flowPath,
|
|
1399
2711
|
cassettePath,
|
|
1400
|
-
replaySourceCassettePath: options.replaySourceCassettePath,
|
|
1401
|
-
replayCommand: formatAgentArgv(replayArgv),
|
|
1402
|
-
commands: {
|
|
1403
|
-
replay: { argv: replayArgv },
|
|
1404
|
-
updateSnapshots: { argv: [...replayArgv, "--update-snapshots"] }
|
|
1405
|
-
},
|
|
1406
|
-
agentFlavor: resolveAgentFlavor(spec),
|
|
1407
|
-
viewports: spec.viewports ?? [],
|
|
1408
|
-
cassetteFrameCount: cassette.frames.length,
|
|
1409
|
-
steps: [],
|
|
1410
|
-
artifacts: [],
|
|
1411
|
-
errors: []
|
|
1412
|
-
};
|
|
1413
|
-
writeFileSync(flowPath, JSON.stringify(spec, null, 2) + "\n", "utf8");
|
|
1414
|
-
let browser = null;
|
|
1415
|
-
try {
|
|
1416
|
-
browser = await launchAgentBrowser({ headless: options.headless ?? true });
|
|
1417
|
-
for (const viewport of spec.viewports ?? []) await runViewport({
|
|
1418
|
-
browser,
|
|
1419
|
-
spec,
|
|
1420
|
-
viewport,
|
|
1421
|
-
rootDir,
|
|
1422
|
-
artifactsDir,
|
|
1423
|
-
snapshotDir,
|
|
1424
|
-
updateSnapshots,
|
|
1425
|
-
recordCassette: !options.replayCassette,
|
|
1426
|
-
cassette,
|
|
1427
|
-
result
|
|
1428
|
-
});
|
|
1429
|
-
} catch (error) {
|
|
1430
|
-
result.ok = false;
|
|
1431
|
-
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
1432
|
-
} finally {
|
|
1433
|
-
await closeBrowserSafely(browser);
|
|
1434
|
-
result.durationMs = Date.now() - startedAt;
|
|
1435
|
-
if (options.replaySourceCassettePath) writeFileSync(cassettePath, readFileSync(options.replaySourceCassettePath, "utf8"), "utf8");
|
|
1436
|
-
else writeFileSync(cassettePath, JSON.stringify(cassette, null, 2) + "\n", "utf8");
|
|
1437
|
-
result.cassetteFrameCount = cassette.frames.length;
|
|
1438
|
-
writeRunRecord(result, spec);
|
|
1439
|
-
writeAgentReport(reportPath, result);
|
|
1440
|
-
writeRunManifest(result);
|
|
1441
|
-
}
|
|
1442
|
-
return result;
|
|
1443
|
-
}
|
|
1444
|
-
async function runViewport(args) {
|
|
1445
|
-
const { browser, spec, viewport, rootDir, artifactsDir, snapshotDir, updateSnapshots, recordCassette, cassette, result } = args;
|
|
1446
|
-
const launchTarget = await resolveAgentLaunchTarget(spec.launch, { rootDir });
|
|
1447
|
-
const context = await browser.newContext({
|
|
1448
|
-
viewport: {
|
|
1449
|
-
width: viewport.width,
|
|
1450
|
-
height: viewport.height
|
|
1451
|
-
},
|
|
1452
|
-
deviceScaleFactor: viewport.deviceScaleFactor,
|
|
1453
|
-
isMobile: viewport.isMobile,
|
|
1454
|
-
hasTouch: viewport.hasTouch
|
|
1455
|
-
});
|
|
1456
|
-
const page = await context.newPage();
|
|
1457
|
-
const ctx = {
|
|
1458
|
-
spec,
|
|
1459
|
-
viewport,
|
|
1460
|
-
page,
|
|
1461
|
-
artifactsDir,
|
|
1462
|
-
snapshotDir,
|
|
1463
2712
|
updateSnapshots,
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
ok: true
|
|
1492
|
-
});
|
|
1493
|
-
await captureCassetteFrame(ctx, {
|
|
1494
|
-
stepIndex: i,
|
|
1495
|
-
stepType: step.type
|
|
1496
|
-
});
|
|
1497
|
-
} catch (error) {
|
|
1498
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1499
|
-
result.ok = false;
|
|
1500
|
-
result.errors.push(`${viewport.name} step ${i + 1} ${step.type}: ${message}`);
|
|
1501
|
-
result.steps.push({
|
|
1502
|
-
index: i,
|
|
1503
|
-
type: step.type,
|
|
1504
|
-
label: formatStepLabel(step),
|
|
1505
|
-
durationMs: Date.now() - started,
|
|
1506
|
-
ok: false,
|
|
1507
|
-
error: message
|
|
1508
|
-
});
|
|
1509
|
-
break;
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
} finally {
|
|
1513
|
-
await withTimeout(context.close().catch(() => void 0), 5e3);
|
|
1514
|
-
await launchTarget.session?.close();
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
async function replayAgentCassette(cassette, cassettePath, options) {
|
|
1518
|
-
const server = await startAgentCassetteServer(cassette);
|
|
1519
|
-
try {
|
|
1520
|
-
const replaySpec = structuredClone(cassette.spec);
|
|
1521
|
-
const replayCassette = structuredClone(cassette);
|
|
1522
|
-
return await runAgentSpec({
|
|
1523
|
-
...replaySpec,
|
|
1524
|
-
launch: {
|
|
1525
|
-
mode: "url",
|
|
1526
|
-
url: server.url,
|
|
1527
|
-
agentFlavor: replaySpec.launch.agentFlavor
|
|
1528
|
-
}
|
|
1529
|
-
}, {
|
|
1530
|
-
...options,
|
|
1531
|
-
artifactsDir: options.artifactsDir ?? join(dirname(cassettePath), "replay"),
|
|
1532
|
-
replayCassette,
|
|
1533
|
-
replaySourceCassettePath: cassettePath
|
|
1534
|
-
});
|
|
1535
|
-
} finally {
|
|
1536
|
-
await server.close();
|
|
1537
|
-
}
|
|
2713
|
+
cassette,
|
|
2714
|
+
result: {
|
|
2715
|
+
ok: true,
|
|
2716
|
+
name,
|
|
2717
|
+
mode: options.replayCassette ? "replay" : "live",
|
|
2718
|
+
startedAt,
|
|
2719
|
+
durationMs: 0,
|
|
2720
|
+
artifactsDir,
|
|
2721
|
+
snapshotDir,
|
|
2722
|
+
reportPath,
|
|
2723
|
+
recordPath,
|
|
2724
|
+
flowPath,
|
|
2725
|
+
cassettePath,
|
|
2726
|
+
replaySourceCassettePath: options.replaySourceCassettePath,
|
|
2727
|
+
replayCommand: formatAgentArgv(replayArgv),
|
|
2728
|
+
commands: {
|
|
2729
|
+
replay: { argv: replayArgv },
|
|
2730
|
+
updateSnapshots: { argv: [...replayArgv, "--update-snapshots"] }
|
|
2731
|
+
},
|
|
2732
|
+
agentFlavor: resolveAgentFlavor(spec),
|
|
2733
|
+
viewports: spec.viewports ?? [],
|
|
2734
|
+
cassetteFrameCount: cassette.frames.length,
|
|
2735
|
+
steps: [],
|
|
2736
|
+
artifacts: [],
|
|
2737
|
+
errors: []
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
1538
2740
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
await withTimeout(browser.close().catch(() => void 0), 5e3);
|
|
2741
|
+
function formatAgentLaunchPlan(input) {
|
|
2742
|
+
return formatAgentLaunchCommand(normalizeAgentFlowSpec(input).launch);
|
|
1542
2743
|
}
|
|
2744
|
+
//#endregion
|
|
2745
|
+
//#region src/agent/timeout.ts
|
|
1543
2746
|
async function withTimeout(promise, timeoutMs) {
|
|
1544
2747
|
let timer;
|
|
1545
2748
|
try {
|
|
@@ -1550,85 +2753,95 @@ async function withTimeout(promise, timeoutMs) {
|
|
|
1550
2753
|
if (timer) clearTimeout(timer);
|
|
1551
2754
|
}
|
|
1552
2755
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2756
|
+
//#endregion
|
|
2757
|
+
//#region src/agent/step_label.ts
|
|
2758
|
+
function formatAgentStepLabel(step) {
|
|
2759
|
+
if (step.type === "snapshot") return `snapshot ${step.name}`;
|
|
2760
|
+
if (step.type === "waitForText") return `wait ${step.text ?? step.regex ?? ""}`;
|
|
2761
|
+
if (step.type === "typeText") return `type ${step.text.slice(0, 24)}`;
|
|
2762
|
+
if (step.type === "pressKey") return `press ${step.key}`;
|
|
2763
|
+
if (step.type === "click") return `click ${step.selector ?? step.text ?? `${step.x},${step.y}`}`;
|
|
2764
|
+
if (step.type === "mark") return `mark ${step.label ?? ""}`;
|
|
2765
|
+
if (step.type === "sleep") return `sleep ${step.ms}ms`;
|
|
2766
|
+
return step.type;
|
|
2767
|
+
}
|
|
2768
|
+
//#endregion
|
|
2769
|
+
//#region src/agent/snapshot_diff.ts
|
|
2770
|
+
function renderSnapshotDiff(expected, received) {
|
|
2771
|
+
const expectedLines = expected.split("\n");
|
|
2772
|
+
const receivedLines = received.split("\n");
|
|
2773
|
+
const max = Math.max(expectedLines.length, receivedLines.length);
|
|
2774
|
+
const out = ["--- expected", "+++ received"];
|
|
2775
|
+
for (let i = 0; i < max; i += 1) {
|
|
2776
|
+
const before = expectedLines[i];
|
|
2777
|
+
const after = receivedLines[i];
|
|
2778
|
+
if (before === after) {
|
|
2779
|
+
if (before !== void 0) out.push(` ${before}`);
|
|
2780
|
+
continue;
|
|
1569
2781
|
}
|
|
1570
|
-
|
|
2782
|
+
if (before !== void 0) out.push(`- ${before}`);
|
|
2783
|
+
if (after !== void 0) out.push(`+ ${after}`);
|
|
1571
2784
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2785
|
+
return out.join("\n") + "\n";
|
|
2786
|
+
}
|
|
2787
|
+
//#endregion
|
|
2788
|
+
//#region src/agent/terminal_dom.ts
|
|
2789
|
+
async function waitForTerminalRoot(page, timeoutMs) {
|
|
2790
|
+
await page.locator("[data-terminal-root]").first().waitFor({
|
|
2791
|
+
state: "attached",
|
|
2792
|
+
timeout: timeoutMs
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
async function waitForTerminalText(page, args) {
|
|
2796
|
+
const started = Date.now();
|
|
2797
|
+
const matcher = args.regex ? new RegExp(args.regex) : null;
|
|
2798
|
+
while (Date.now() - started < args.timeoutMs) {
|
|
2799
|
+
const text = await readTerminalText(page);
|
|
2800
|
+
if (args.text && text.includes(args.text)) return;
|
|
2801
|
+
if (matcher?.test(text)) return;
|
|
2802
|
+
await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
|
|
1576
2803
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
2804
|
+
throw new Error(`timed out waiting for terminal text ${args.text ?? args.regex ?? ""}`);
|
|
2805
|
+
}
|
|
2806
|
+
async function waitForStableDom(page, args) {
|
|
2807
|
+
const started = Date.now();
|
|
2808
|
+
let last = "";
|
|
2809
|
+
let stableSince = Date.now();
|
|
2810
|
+
while (Date.now() - started < args.timeoutMs) {
|
|
2811
|
+
const current = await readTerminalDomIfPresent(page);
|
|
2812
|
+
if (current === null) {
|
|
2813
|
+
await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
|
|
2814
|
+
continue;
|
|
1587
2815
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
await
|
|
1593
|
-
timeoutMs: step.timeoutMs ?? timeout,
|
|
1594
|
-
quietMs: step.quietMs ?? 350,
|
|
1595
|
-
intervalMs: step.intervalMs ?? 100
|
|
1596
|
-
});
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
if (step.type === "snapshot") {
|
|
1600
|
-
await captureSnapshotStep(ctx, step);
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1603
|
-
if (step.type === "sleep") {
|
|
1604
|
-
if (!ctx.replay) await new Promise((resolveSleep) => setTimeout(resolveSleep, step.ms));
|
|
1605
|
-
return;
|
|
2816
|
+
if (current !== last) {
|
|
2817
|
+
last = current;
|
|
2818
|
+
stableSince = Date.now();
|
|
2819
|
+
} else if (Date.now() - stableSince >= args.quietMs) return;
|
|
2820
|
+
await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
|
|
1606
2821
|
}
|
|
1607
|
-
|
|
2822
|
+
throw new Error(`timed out waiting for stable terminal DOM`);
|
|
1608
2823
|
}
|
|
1609
|
-
async function
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
stepType: frame.stepType,
|
|
1617
|
-
terminalText: normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks),
|
|
1618
|
-
dom: normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks),
|
|
1619
|
-
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2824
|
+
async function readTerminalText(page) {
|
|
2825
|
+
const text = await page.evaluate(() => {
|
|
2826
|
+
const node = document.querySelector("[data-terminal-root]");
|
|
2827
|
+
if (!node) return null;
|
|
2828
|
+
const rows = Array.from(node.querySelectorAll(".term-grid .term-row"));
|
|
2829
|
+
if (rows.length > 0) return rows.map((row) => row.textContent ?? "").join("\n");
|
|
2830
|
+
return node.textContent ?? "";
|
|
1620
2831
|
});
|
|
2832
|
+
if (text === null) throw new Error("terminal root is not attached");
|
|
2833
|
+
return text;
|
|
1621
2834
|
}
|
|
1622
|
-
async function
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
window.__ptywrightReplaySetPhase?.(phase);
|
|
1630
|
-
}, ctx.nextReplayPhase);
|
|
2835
|
+
async function readTerminalDom(page) {
|
|
2836
|
+
const dom = await readTerminalDomIfPresent(page);
|
|
2837
|
+
if (dom === null) throw new Error("terminal root is not attached");
|
|
2838
|
+
return dom;
|
|
2839
|
+
}
|
|
2840
|
+
async function readTerminalDomIfPresent(page) {
|
|
2841
|
+
return page.evaluate(() => document.querySelector("[data-terminal-root]")?.innerHTML ?? null);
|
|
1631
2842
|
}
|
|
2843
|
+
//#endregion
|
|
2844
|
+
//#region src/agent/snapshot_artifacts.ts
|
|
1632
2845
|
async function captureSnapshotStep(ctx, step) {
|
|
1633
2846
|
const targets = step.targets ?? [
|
|
1634
2847
|
"terminal",
|
|
@@ -1636,7 +2849,8 @@ async function captureSnapshotStep(ctx, step) {
|
|
|
1636
2849
|
...ctx.spec.defaults?.screenshot ? ["screenshot"] : []
|
|
1637
2850
|
];
|
|
1638
2851
|
const base = `${sanitizeArtifactName(ctx.viewport.name)}.${sanitizeArtifactName(step.name)}`;
|
|
1639
|
-
|
|
2852
|
+
const errors = [];
|
|
2853
|
+
for (const target of targets) try {
|
|
1640
2854
|
if (target === "terminal") {
|
|
1641
2855
|
const text = normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks);
|
|
1642
2856
|
await writeComparableArtifact(ctx, {
|
|
@@ -1675,7 +2889,10 @@ async function captureSnapshotStep(ctx, step) {
|
|
|
1675
2889
|
path: screenshotPath,
|
|
1676
2890
|
ok: true
|
|
1677
2891
|
});
|
|
2892
|
+
} catch (error) {
|
|
2893
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
1678
2894
|
}
|
|
2895
|
+
if (errors.length > 0) throw new Error(errors.join("; "));
|
|
1679
2896
|
}
|
|
1680
2897
|
async function writeComparableArtifact(ctx, artifact) {
|
|
1681
2898
|
const artifactPath = join(ctx.artifactsDir, artifact.relativePath);
|
|
@@ -1752,172 +2969,255 @@ async function writeComparableArtifact(ctx, artifact) {
|
|
|
1752
2969
|
ok: true
|
|
1753
2970
|
});
|
|
1754
2971
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2972
|
+
//#endregion
|
|
2973
|
+
//#region src/agent/step_runner.ts
|
|
2974
|
+
async function runAgentStep(ctx, step) {
|
|
2975
|
+
const timeout = ctx.spec.defaults?.timeoutMs ?? 3e4;
|
|
2976
|
+
if (step.type === "waitForText") {
|
|
2977
|
+
await waitForTerminalText(ctx.page, {
|
|
2978
|
+
text: step.text,
|
|
2979
|
+
regex: step.regex,
|
|
2980
|
+
timeoutMs: step.timeoutMs ?? timeout
|
|
2981
|
+
});
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
if (step.type === "typeText") {
|
|
2985
|
+
await advanceReplayPhase(ctx);
|
|
2986
|
+
if (!ctx.replay) {
|
|
2987
|
+
await ctx.page.locator("[data-terminal-root]").first().click({ timeout });
|
|
2988
|
+
await ctx.page.keyboard.type(step.text, { delay: step.delayMs });
|
|
2989
|
+
if (step.enter) await ctx.page.keyboard.press("Enter");
|
|
1766
2990
|
}
|
|
1767
|
-
|
|
1768
|
-
if (after !== void 0) out.push(`+ ${after}`);
|
|
2991
|
+
return;
|
|
1769
2992
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
state: "attached",
|
|
1775
|
-
timeout: timeoutMs
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
async function waitForTerminalText(page, args) {
|
|
1779
|
-
const started = Date.now();
|
|
1780
|
-
const matcher = args.regex ? new RegExp(args.regex) : null;
|
|
1781
|
-
while (Date.now() - started < args.timeoutMs) {
|
|
1782
|
-
const text = await readTerminalText(page);
|
|
1783
|
-
if (args.text && text.includes(args.text)) return;
|
|
1784
|
-
if (matcher?.test(text)) return;
|
|
1785
|
-
await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
|
|
2993
|
+
if (step.type === "pressKey") {
|
|
2994
|
+
await advanceReplayPhase(ctx);
|
|
2995
|
+
if (!ctx.replay) await ctx.page.keyboard.press(step.key);
|
|
2996
|
+
return;
|
|
1786
2997
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
while (Date.now() - started < args.timeoutMs) {
|
|
1794
|
-
const current = await readTerminalDomIfPresent(page);
|
|
1795
|
-
if (current === null) {
|
|
1796
|
-
await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
|
|
1797
|
-
continue;
|
|
2998
|
+
if (step.type === "click") {
|
|
2999
|
+
await advanceReplayPhase(ctx);
|
|
3000
|
+
if (ctx.replay) return;
|
|
3001
|
+
if (step.selector) {
|
|
3002
|
+
await ctx.page.locator(step.selector).first().click({ timeout });
|
|
3003
|
+
return;
|
|
1798
3004
|
}
|
|
1799
|
-
if (
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
}
|
|
1803
|
-
await
|
|
3005
|
+
if (step.text) {
|
|
3006
|
+
await ctx.page.getByText(step.text, { exact: false }).first().click({ timeout });
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
await ctx.page.mouse.click(step.x, step.y);
|
|
3010
|
+
return;
|
|
1804
3011
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
3012
|
+
if (step.type === "waitForStableDom") {
|
|
3013
|
+
await waitForStableDom(ctx.page, {
|
|
3014
|
+
timeoutMs: step.timeoutMs ?? timeout,
|
|
3015
|
+
quietMs: step.quietMs ?? 350,
|
|
3016
|
+
intervalMs: step.intervalMs ?? 100
|
|
3017
|
+
});
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (step.type === "snapshot") {
|
|
3021
|
+
await captureSnapshotStep(ctx, step);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
if (step.type === "sleep") {
|
|
3025
|
+
if (!ctx.replay) await new Promise((resolveSleep) => setTimeout(resolveSleep, step.ms));
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
if (step.type === "mark") return;
|
|
1817
3029
|
}
|
|
1818
|
-
async function
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
3030
|
+
async function captureCassetteFrame(ctx, frame) {
|
|
3031
|
+
if (!ctx.cassette || !ctx.recordCassette) return;
|
|
3032
|
+
const phase = ctx.nextReplayPhase;
|
|
3033
|
+
upsertAgentCassetteFrame(ctx.cassette, {
|
|
3034
|
+
viewport: ctx.viewport,
|
|
3035
|
+
phase,
|
|
3036
|
+
stepIndex: frame.stepIndex,
|
|
3037
|
+
stepType: frame.stepType,
|
|
3038
|
+
terminalText: normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks),
|
|
3039
|
+
dom: normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks),
|
|
3040
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3041
|
+
});
|
|
1822
3042
|
}
|
|
1823
|
-
async function
|
|
1824
|
-
|
|
3043
|
+
async function advanceReplayPhase(ctx) {
|
|
3044
|
+
if (!ctx.replay) {
|
|
3045
|
+
ctx.nextReplayPhase += 1;
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
ctx.nextReplayPhase += 1;
|
|
3049
|
+
await ctx.page.evaluate((phase) => {
|
|
3050
|
+
window.__ptywrightReplaySetPhase?.(phase);
|
|
3051
|
+
}, ctx.nextReplayPhase);
|
|
1825
3052
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
3053
|
+
//#endregion
|
|
3054
|
+
//#region src/agent/viewport_runner.ts
|
|
3055
|
+
async function runAgentViewport(args) {
|
|
3056
|
+
const { browser, spec, viewport, rootDir, artifactsDir, snapshotDir, updateSnapshots, recordCassette, cassette, result } = args;
|
|
3057
|
+
const launchTarget = await resolveAgentLaunchTarget(spec.launch, { rootDir });
|
|
3058
|
+
const context = await browser.newContext({
|
|
3059
|
+
viewport: {
|
|
3060
|
+
width: viewport.width,
|
|
3061
|
+
height: viewport.height
|
|
3062
|
+
},
|
|
3063
|
+
deviceScaleFactor: viewport.deviceScaleFactor,
|
|
3064
|
+
isMobile: viewport.isMobile,
|
|
3065
|
+
hasTouch: viewport.hasTouch
|
|
3066
|
+
});
|
|
3067
|
+
const page = await context.newPage();
|
|
3068
|
+
const ctx = {
|
|
1835
3069
|
spec,
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
commands: result.commands,
|
|
1844
|
-
steps: result.steps,
|
|
3070
|
+
viewport,
|
|
3071
|
+
page,
|
|
3072
|
+
artifactsDir,
|
|
3073
|
+
snapshotDir,
|
|
3074
|
+
updateSnapshots,
|
|
3075
|
+
recordCassette,
|
|
3076
|
+
masks: resolveAgentMasks(spec),
|
|
1845
3077
|
artifacts: result.artifacts,
|
|
1846
|
-
|
|
3078
|
+
replay: Boolean(args.cassette && !args.recordCassette),
|
|
3079
|
+
cassette,
|
|
3080
|
+
nextReplayPhase: 0
|
|
1847
3081
|
};
|
|
1848
|
-
|
|
3082
|
+
try {
|
|
3083
|
+
await page.goto(resolveViewportUrl(launchTarget.url, viewport), {
|
|
3084
|
+
waitUntil: "domcontentloaded",
|
|
3085
|
+
timeout: spec.defaults?.timeoutMs ?? 3e4
|
|
3086
|
+
});
|
|
3087
|
+
await waitForTerminalRoot(page, spec.defaults?.timeoutMs ?? 3e4);
|
|
3088
|
+
await captureCassetteFrame(ctx, {
|
|
3089
|
+
stepIndex: null,
|
|
3090
|
+
stepType: "initial"
|
|
3091
|
+
});
|
|
3092
|
+
for (let i = 0; i < spec.steps.length; i += 1) {
|
|
3093
|
+
const step = spec.steps[i];
|
|
3094
|
+
const started = Date.now();
|
|
3095
|
+
try {
|
|
3096
|
+
await runAgentStep(ctx, step);
|
|
3097
|
+
result.steps.push({
|
|
3098
|
+
index: i,
|
|
3099
|
+
type: step.type,
|
|
3100
|
+
label: formatAgentStepLabel(step),
|
|
3101
|
+
durationMs: Date.now() - started,
|
|
3102
|
+
ok: true
|
|
3103
|
+
});
|
|
3104
|
+
await captureCassetteFrame(ctx, {
|
|
3105
|
+
stepIndex: i,
|
|
3106
|
+
stepType: step.type
|
|
3107
|
+
});
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3110
|
+
result.ok = false;
|
|
3111
|
+
result.errors.push(`${viewport.name} step ${i + 1} ${step.type}: ${message}`);
|
|
3112
|
+
result.steps.push({
|
|
3113
|
+
index: i,
|
|
3114
|
+
type: step.type,
|
|
3115
|
+
label: formatAgentStepLabel(step),
|
|
3116
|
+
durationMs: Date.now() - started,
|
|
3117
|
+
ok: false,
|
|
3118
|
+
error: message
|
|
3119
|
+
});
|
|
3120
|
+
break;
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
} finally {
|
|
3124
|
+
await withTimeout(context.close().catch(() => void 0), 5e3);
|
|
3125
|
+
await launchTarget.session?.close();
|
|
3126
|
+
}
|
|
1849
3127
|
}
|
|
1850
|
-
function
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
{
|
|
1878
|
-
path: result.recordPath,
|
|
1879
|
-
kind: "run-record",
|
|
1880
|
-
role: "record",
|
|
1881
|
-
ok: result.ok
|
|
1882
|
-
},
|
|
1883
|
-
{
|
|
1884
|
-
path: result.reportPath,
|
|
1885
|
-
kind: "report",
|
|
1886
|
-
role: "report",
|
|
1887
|
-
ok: result.ok
|
|
1888
|
-
},
|
|
1889
|
-
...result.artifacts.flatMap((artifact) => [{
|
|
1890
|
-
path: artifact.path,
|
|
1891
|
-
kind: artifact.kind,
|
|
1892
|
-
role: "artifact",
|
|
1893
|
-
ok: artifact.ok
|
|
1894
|
-
}, {
|
|
1895
|
-
path: artifact.diffPath,
|
|
1896
|
-
kind: "diff",
|
|
1897
|
-
role: "diff",
|
|
1898
|
-
ok: artifact.ok
|
|
1899
|
-
}])
|
|
1900
|
-
]
|
|
3128
|
+
function resolveViewportUrl(url, viewport) {
|
|
3129
|
+
return url.replaceAll("{viewportName}", encodeURIComponent(viewport.name)).replaceAll("{viewportWidth}", encodeURIComponent(String(viewport.width))).replaceAll("{viewportHeight}", encodeURIComponent(String(viewport.height)));
|
|
3130
|
+
}
|
|
3131
|
+
//#endregion
|
|
3132
|
+
//#region src/agent/runner.ts
|
|
3133
|
+
async function runAgentSpecPath(specPath, options = {}) {
|
|
3134
|
+
return runAgentSpec((await loadAgentSpec(specPath)).raw, options);
|
|
3135
|
+
}
|
|
3136
|
+
async function replayAgentRecordPath(recordPath, options = {}) {
|
|
3137
|
+
const raw = JSON.parse(readFileSync(recordPath, "utf8"));
|
|
3138
|
+
if (isAgentCassetteLike(raw)) return replayAgentCassette(normalizeAgentCassette(raw), recordPath, options);
|
|
3139
|
+
const record = readAgentRunRecordPath(recordPath);
|
|
3140
|
+
if (record.cassettePath) {
|
|
3141
|
+
const cassettePath = isAbsolute(record.cassettePath) ? record.cassettePath : resolve(dirname(recordPath), record.cassettePath);
|
|
3142
|
+
return replayAgentCassette(readAgentCassettePath(cassettePath, record.spec), cassettePath, {
|
|
3143
|
+
...options,
|
|
3144
|
+
artifactsDir: options.artifactsDir ?? join(dirname(recordPath), "replay")
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
if (record.spec) return runAgentSpec(record.spec, {
|
|
3148
|
+
...options,
|
|
3149
|
+
config: void 0
|
|
3150
|
+
});
|
|
3151
|
+
if (!record.flowPath) throw new Error(`invalid agent run record: missing replay source in ${recordPath}`);
|
|
3152
|
+
return runAgentSpecPath(isAbsolute(record.flowPath) ? record.flowPath : resolve(dirname(recordPath), record.flowPath), {
|
|
3153
|
+
...options,
|
|
3154
|
+
config: void 0
|
|
1901
3155
|
});
|
|
1902
3156
|
}
|
|
1903
|
-
function
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
3157
|
+
async function runAgentSpec(input, options = {}) {
|
|
3158
|
+
const { artifactsDir, cassette, cassettePath, flowPath, reportPath, result, rootDir, snapshotDir, spec, startedAt, updateSnapshots } = prepareAgentRun(input, options);
|
|
3159
|
+
writeFlowArtifact(flowPath, spec);
|
|
3160
|
+
let browser = null;
|
|
3161
|
+
try {
|
|
3162
|
+
browser = await launchAgentBrowser({ headless: options.headless ?? true });
|
|
3163
|
+
for (const viewport of spec.viewports ?? []) await runAgentViewport({
|
|
3164
|
+
browser,
|
|
3165
|
+
spec,
|
|
3166
|
+
viewport,
|
|
3167
|
+
rootDir,
|
|
3168
|
+
artifactsDir,
|
|
3169
|
+
snapshotDir,
|
|
3170
|
+
updateSnapshots,
|
|
3171
|
+
recordCassette: !options.replayCassette,
|
|
3172
|
+
cassette,
|
|
3173
|
+
result
|
|
3174
|
+
});
|
|
3175
|
+
} catch (error) {
|
|
3176
|
+
result.ok = false;
|
|
3177
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
3178
|
+
} finally {
|
|
3179
|
+
await closeBrowserSafely(browser);
|
|
3180
|
+
result.durationMs = Date.now() - startedAt;
|
|
3181
|
+
if (options.replaySourceCassettePath) writeFileSync(cassettePath, readFileSync(options.replaySourceCassettePath, "utf8"), "utf8");
|
|
3182
|
+
else writeFileSync(cassettePath, JSON.stringify(cassette, null, 2) + "\n", "utf8");
|
|
3183
|
+
result.cassetteFrameCount = cassette.frames.length;
|
|
3184
|
+
writeRunRecord(result, spec);
|
|
3185
|
+
await writeAgentReport(reportPath, result, { config: options.config });
|
|
3186
|
+
writeRunManifest(result);
|
|
3187
|
+
}
|
|
3188
|
+
return result;
|
|
3189
|
+
}
|
|
3190
|
+
async function replayAgentCassette(cassette, cassettePath, options) {
|
|
3191
|
+
const server = await startAgentCassetteServer(cassette);
|
|
3192
|
+
try {
|
|
3193
|
+
const replaySpec = structuredClone(cassette.spec);
|
|
3194
|
+
const replayCassette = structuredClone(cassette);
|
|
3195
|
+
return await runAgentSpec({
|
|
3196
|
+
...replaySpec,
|
|
3197
|
+
launch: {
|
|
3198
|
+
mode: "url",
|
|
3199
|
+
url: withReplayViewportQuery(server.url),
|
|
3200
|
+
agentFlavor: replaySpec.launch.agentFlavor
|
|
3201
|
+
}
|
|
3202
|
+
}, {
|
|
3203
|
+
...options,
|
|
3204
|
+
artifactsDir: options.artifactsDir ?? join(dirname(cassettePath), "replay"),
|
|
3205
|
+
replayCassette,
|
|
3206
|
+
replaySourceCassettePath: cassettePath
|
|
3207
|
+
});
|
|
3208
|
+
} finally {
|
|
3209
|
+
await server.close();
|
|
3210
|
+
}
|
|
1912
3211
|
}
|
|
1913
|
-
function
|
|
1914
|
-
return
|
|
3212
|
+
function withReplayViewportQuery(url) {
|
|
3213
|
+
return `${url}${url.includes("?") ? "&" : "?"}viewportName={viewportName}&viewportWidth={viewportWidth}&viewportHeight={viewportHeight}`;
|
|
1915
3214
|
}
|
|
1916
|
-
function
|
|
1917
|
-
|
|
3215
|
+
async function closeBrowserSafely(browser) {
|
|
3216
|
+
if (!browser) return;
|
|
3217
|
+
await withTimeout(browser.close().catch(() => void 0), 5e3);
|
|
1918
3218
|
}
|
|
1919
3219
|
function defaultSpecNameForPath(path) {
|
|
1920
3220
|
return sanitizeArtifactName(basename(path, extname(path)));
|
|
1921
3221
|
}
|
|
1922
3222
|
//#endregion
|
|
1923
|
-
export {
|
|
3223
|
+
export { writeAgentManifestPath as C, escapeHtml as D, escapeAttribute as E, normalizeAgentFlowSpec as O, validateAgentManifestFiles as S, readAgentCassettePath as T, formatArgv as _, formatAgentLaunchPlan as a, isAgentManifestLike as b, launchAgentBrowser as c, AGENT_RUN_RECORD_SCHEMA_URL as d, agentRunModeSchema as f, writeAgentRunRecordPath as g, readAgentRunRecordPath as h, runAgentSpecPath as i, sanitizeArtifactName as k, createAgentTemplateSpec as l, isAgentRunRecordLike as m, replayAgentRecordPath as n, resolveAgentLaunchTarget as o, formatAgentArgv as p, runAgentSpec as r, normalizeAgentFlowSpecWithConfig as s, defaultSpecNameForPath as t, loadAgentSpec as u, AGENT_MANIFEST_FILE_NAME as v, isAgentCassetteLike as w, readAgentManifestPath as x, agentManifestPath as y };
|