ptywright 0.3.0 → 0.5.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.
@@ -1,223 +1,20 @@
1
- import { z } from "zod";
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 { createRequire } from "node:module";
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
6
  import { pathToFileURL } from "node:url";
4
- import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
- import { createHash } from "node:crypto";
6
- import { chromium } from "playwright";
7
+ import { z } from "zod";
7
8
  import { createServer } from "node:http";
9
+ import { chromium } from "playwright";
8
10
  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
11
  //#region src/agent/normalize.ts
215
- function applyAgentMasks(input, rules = []) {
12
+ function applyAgentMasks(input, rules = [], options = {}) {
216
13
  let out = input;
217
14
  for (const rule of rules) {
218
15
  const flags = rule.flags ?? "g";
219
16
  const regex = new RegExp(rule.regex, flags.includes("g") ? flags : `${flags}g`);
220
- const replacement = rule.replacement ?? "<masked>";
17
+ const replacement = options.replacement?.(rule.replacement ?? "<masked>") ?? rule.replacement ?? "<masked>";
221
18
  out = out.replace(regex, (match) => {
222
19
  if (!rule.preserveLength) return replacement;
223
20
  if (replacement.length === match.length) return replacement;
@@ -234,7 +31,7 @@ function normalizeTerminalText(input, rules = []) {
234
31
  return lines.join("\n");
235
32
  }
236
33
  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);
34
+ 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
35
  }
239
36
  function sanitizeArtifactName(input) {
240
37
  return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "artifact";
@@ -247,6 +44,9 @@ function shortHash(input) {
247
44
  }
248
45
  return hash.toString(16).padStart(8, "0");
249
46
  }
47
+ function escapeHtmlText(input) {
48
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49
+ }
250
50
  //#endregion
251
51
  //#region src/agent/schema.ts
252
52
  const agentTextMaskRuleSchema = z.object({
@@ -394,73 +194,15 @@ function resolveAgentLaunchMode(launch) {
394
194
  return inferAgentLaunchMode(launch);
395
195
  }
396
196
  //#endregion
397
- //#region src/agent/cassette.ts
398
- const AGENT_CASSETTE_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json";
399
- const agentCassetteFrameSchema = z.object({
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);
197
+ //#region src/agent/html_escape.ts
198
+ function escapeHtml(input) {
199
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
450
200
  }
451
- function upsertAgentCassetteFrame(cassette, frame) {
452
- const next = {
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);
201
+ function escapeAttribute(input) {
202
+ return escapeHtml(input).replace(/'/g, "&#39;");
463
203
  }
204
+ //#endregion
205
+ //#region src/agent/cassette_server.ts
464
206
  async function startAgentCassetteServer(cassette) {
465
207
  const sockets = /* @__PURE__ */ new Set();
466
208
  const server = createServer((request, response) => {
@@ -496,7 +238,7 @@ async function startAgentCassetteServer(cassette) {
496
238
  });
497
239
  const address = server.address();
498
240
  if (!address || typeof address === "string") {
499
- await closeServer(server);
241
+ await closeServer(server, sockets);
500
242
  throw new Error("failed to bind cassette replay server");
501
243
  }
502
244
  return {
@@ -511,7 +253,7 @@ function renderCassetteHtml(cassette) {
511
253
  <head>
512
254
  <meta charset="utf-8" />
513
255
  <meta name="viewport" content="width=device-width, initial-scale=1" />
514
- <title>${escapeHtml$1(cassette.name)} cassette replay</title>
256
+ <title>${escapeHtml(cassette.name)} cassette replay</title>
515
257
  <style>
516
258
  :root {
517
259
  color-scheme: dark;
@@ -542,10 +284,25 @@ function renderCassetteHtml(cassette) {
542
284
  const root = document.querySelector("[data-terminal-root]");
543
285
  let phase = 0;
544
286
 
287
+ function queryViewport() {
288
+ const params = new URLSearchParams(window.location.search);
289
+ const width = Number.parseInt(params.get("viewportWidth") || "", 10);
290
+ const height = Number.parseInt(params.get("viewportHeight") || "", 10);
291
+ const name = params.get("viewportName") || "";
292
+ return {
293
+ height: Number.isFinite(height) && height > 0 ? height : window.innerHeight,
294
+ name,
295
+ width: Number.isFinite(width) && width > 0 ? width : window.innerWidth,
296
+ };
297
+ }
298
+
545
299
  function viewportScore(frame) {
546
300
  const viewport = frame.viewport || {};
547
- return Math.abs((viewport.width || window.innerWidth) - window.innerWidth) +
548
- Math.abs((viewport.height || window.innerHeight) - window.innerHeight);
301
+ const target = queryViewport();
302
+ const namePenalty = target.name && viewport.name !== target.name ? 100000 : 0;
303
+ return namePenalty +
304
+ Math.abs((viewport.width || target.width) - target.width) +
305
+ Math.abs((viewport.height || target.height) - target.height);
549
306
  }
550
307
 
551
308
  function chooseFrame() {
@@ -620,130 +377,340 @@ async function closeServer(server, sockets) {
620
377
  });
621
378
  });
622
379
  }
623
- function escapeHtml$1(input) {
624
- return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
625
- }
626
380
  //#endregion
627
- //#region src/agent/command_launch.ts
628
- const DEFAULT_URL_REGEX = /https?:\/\/[^\s"'<>]+/;
629
- function buildCommandLaunchCommand(launch, options = {}) {
630
- if (!launch.command) throw new Error("launch.command is required when launch.mode is 'command'");
631
- const rootDir = options.rootDir ?? process.cwd();
632
- const cwd = launch.cwd ? resolve(rootDir, launch.cwd) : rootDir;
633
- return {
634
- file: launch.command,
635
- args: launch.args ?? [],
636
- cwd,
637
- env: {
638
- ...options.env ?? process.env,
639
- ...launch.env
640
- },
641
- label: launch.command,
642
- urlRegex: launch.urlRegex,
643
- waitForUrlMs: launch.waitForUrlMs
381
+ //#region src/agent/cassette.ts
382
+ const AGENT_CASSETTE_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json";
383
+ const agentCassetteFrameSchema = z.object({
384
+ viewport: agentViewportSchema,
385
+ phase: z.number().int().nonnegative(),
386
+ stepIndex: z.number().int().nonnegative().nullable(),
387
+ stepType: z.string().min(1),
388
+ terminalText: z.string(),
389
+ terminalHash: z.string().min(1),
390
+ dom: z.string(),
391
+ domHash: z.string().min(1),
392
+ capturedAt: z.string().min(1)
393
+ });
394
+ const agentCassetteSchema = z.object({
395
+ $schema: z.string().optional(),
396
+ version: z.literal(1),
397
+ name: z.string().min(1),
398
+ createdAt: z.string().min(1),
399
+ spec: agentFlowSpecSchema.optional(),
400
+ frames: z.array(agentCassetteFrameSchema).min(1)
401
+ });
402
+ function createAgentCassette(name, spec) {
403
+ return {
404
+ $schema: AGENT_CASSETTE_SCHEMA_URL,
405
+ version: 1,
406
+ name,
407
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
408
+ spec: normalizeAgentFlowSpec(spec),
409
+ frames: []
644
410
  };
645
411
  }
646
- async function launchBrowserSessionFromCommand(command) {
647
- const timeoutMs = command.waitForUrlMs ?? 15e3;
648
- const child = spawn(command.file, command.args, {
649
- cwd: command.cwd,
650
- env: command.env,
651
- stdio: [
652
- "ignore",
653
- "pipe",
654
- "pipe"
655
- ]
656
- });
657
- const stdoutChunks = [];
658
- const stderrChunks = [];
412
+ function normalizeAgentCassette(input, fallbackSpec) {
413
+ const parsed = agentCassetteSchema.parse(input);
414
+ const specInput = parsed.spec ?? fallbackSpec;
415
+ if (!specInput) throw new Error("invalid agent cassette: missing spec");
416
+ validateCassetteFrameHashes(parsed.frames);
659
417
  return {
660
- url: await new Promise((resolveUrl, reject) => {
661
- let settled = false;
662
- const timer = setTimeout(() => {
663
- 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()}`));
664
- }, timeoutMs);
665
- const finish = (result) => {
666
- if (settled) return;
667
- settled = true;
668
- clearTimeout(timer);
669
- child.stdout.off("data", onStdout);
670
- child.stderr.off("data", onStderr);
671
- child.off("error", onError);
672
- child.off("exit", onExit);
673
- if (result instanceof Error) reject(result);
674
- else resolveUrl(result);
675
- };
676
- const readUrl = () => {
677
- const found = extractUrlFromOutput(`${stdoutChunks.join("")}\n${stderrChunks.join("")}`, command.urlRegex);
678
- if (found) finish(found);
679
- };
680
- const onStdout = (chunk) => {
681
- stdoutChunks.push(chunk.toString("utf8"));
682
- readUrl();
683
- };
684
- const onStderr = (chunk) => {
685
- stderrChunks.push(chunk.toString("utf8"));
686
- readUrl();
687
- };
688
- const onError = (error) => finish(error);
689
- const onExit = (code, signal) => {
690
- 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()}`));
691
- };
692
- child.stdout.on("data", onStdout);
693
- child.stderr.on("data", onStderr);
694
- child.once("error", onError);
695
- child.once("exit", onExit);
696
- }),
697
- process: child,
698
- close: () => closeChild(child)
418
+ ...parsed,
419
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json",
420
+ spec: normalizeAgentFlowSpec(specInput)
421
+ };
422
+ }
423
+ function validateCassetteFrameHashes(frames) {
424
+ for (const frame of frames) {
425
+ if (shortHash(frame.terminalText) !== frame.terminalHash) throw new Error(`invalid agent cassette: terminal hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
426
+ if (shortHash(frame.dom) !== frame.domHash) throw new Error(`invalid agent cassette: dom hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
427
+ }
428
+ }
429
+ function readAgentCassettePath(path, fallbackSpec) {
430
+ return normalizeAgentCassette(JSON.parse(readFileSync(path, "utf8")), fallbackSpec);
431
+ }
432
+ function isAgentCassetteLike(input) {
433
+ return typeof input === "object" && input !== null && input.version === 1 && Array.isArray(input.frames);
434
+ }
435
+ function upsertAgentCassetteFrame(cassette, frame) {
436
+ const next = {
437
+ ...frame,
438
+ terminalHash: shortHash(frame.terminalText),
439
+ domHash: shortHash(frame.dom)
699
440
  };
441
+ const index = cassette.frames.findIndex((candidate) => candidate.viewport.name === next.viewport.name && candidate.phase === next.phase);
442
+ if (index >= 0) {
443
+ cassette.frames[index] = next;
444
+ return;
445
+ }
446
+ cassette.frames.push(next);
447
+ }
448
+ //#endregion
449
+ //#region src/agent/manifest.ts
450
+ const AGENT_MANIFEST_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json";
451
+ const AGENT_MANIFEST_FILE_NAME = "ptywright-agent.manifest.json";
452
+ const agentManifestKindSchema = z.enum([
453
+ "run",
454
+ "replay-suite",
455
+ "check",
456
+ "promote"
457
+ ]);
458
+ const agentManifestFileKindSchema = z.enum([
459
+ "flow",
460
+ "cassette",
461
+ "run-record",
462
+ "replay-summary",
463
+ "check-summary",
464
+ "promote-summary",
465
+ "report",
466
+ "terminal",
467
+ "dom",
468
+ "screenshot",
469
+ "diff"
470
+ ]);
471
+ const agentManifestCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
472
+ const agentManifestValidationStageSchema = z.object({
473
+ name: z.string().min(1),
474
+ ok: z.boolean(),
475
+ totalCount: z.number().int().nonnegative(),
476
+ failureCount: z.number().int().nonnegative()
477
+ }).strict();
478
+ const agentManifestValidationSchema = z.object({
479
+ ok: z.boolean(),
480
+ stages: z.array(agentManifestValidationStageSchema)
481
+ }).strict().superRefine((validation, ctx) => {
482
+ const ok = validation.stages.every((stage) => stage.ok && stage.failureCount === 0);
483
+ if (validation.ok !== ok) ctx.addIssue({
484
+ code: z.ZodIssueCode.custom,
485
+ path: ["ok"],
486
+ message: "validation ok must match validation stages"
487
+ });
488
+ });
489
+ const agentManifestFileSchema = z.object({
490
+ path: z.string().min(1),
491
+ kind: agentManifestFileKindSchema,
492
+ role: z.string().min(1).optional(),
493
+ ok: z.boolean().optional(),
494
+ bytes: z.number().int().nonnegative(),
495
+ sha256: z.string().regex(/^[a-f0-9]{64}$/)
496
+ }).strict();
497
+ const agentManifestSchema = z.object({
498
+ $schema: z.string().optional(),
499
+ version: z.literal(1),
500
+ kind: agentManifestKindSchema,
501
+ ok: z.boolean(),
502
+ generatedAt: z.string().min(1),
503
+ rootDir: z.string().min(1),
504
+ primaryPath: z.string().min(1),
505
+ commands: z.record(agentManifestCommandSchema),
506
+ validation: agentManifestValidationSchema.optional(),
507
+ files: z.array(agentManifestFileSchema)
508
+ }).strict().superRefine((manifest, ctx) => {
509
+ const seen = /* @__PURE__ */ new Set();
510
+ for (const file of manifest.files) {
511
+ if (seen.has(file.path)) ctx.addIssue({
512
+ code: z.ZodIssueCode.custom,
513
+ path: ["files"],
514
+ message: `duplicate manifest file path: ${file.path}`
515
+ });
516
+ seen.add(file.path);
517
+ }
518
+ });
519
+ function agentManifestPath(rootDir) {
520
+ return join(rootDir, AGENT_MANIFEST_FILE_NAME);
521
+ }
522
+ function createAgentManifest(options) {
523
+ return normalizeAgentManifest({
524
+ $schema: AGENT_MANIFEST_SCHEMA_URL,
525
+ version: 1,
526
+ kind: options.kind,
527
+ ok: options.ok,
528
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
529
+ rootDir: options.rootDir,
530
+ primaryPath: options.primaryPath,
531
+ commands: options.commands,
532
+ validation: options.validation,
533
+ files: collectManifestFiles(options.files, options.rootDir)
534
+ });
535
+ }
536
+ function readAgentManifestPath(path) {
537
+ return normalizeAgentManifest(JSON.parse(readFileSync(path, "utf8")));
538
+ }
539
+ function writeAgentManifestPath(path, options) {
540
+ const manifest = createAgentManifest(options);
541
+ mkdirSync(dirname(path), { recursive: true });
542
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf8");
543
+ return manifest;
544
+ }
545
+ function normalizeAgentManifest(input) {
546
+ try {
547
+ const parsed = agentManifestSchema.parse(input);
548
+ return {
549
+ ...parsed,
550
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-manifest.schema.json"
551
+ };
552
+ } catch (error) {
553
+ if (error instanceof z.ZodError) throw new Error(`invalid agent manifest: ${formatZodIssues(error)}`);
554
+ throw error;
555
+ }
556
+ }
557
+ function validateAgentManifestFiles(manifest, manifestPath) {
558
+ validateManifestFiles({
559
+ files: manifest.files,
560
+ manifestPath,
561
+ rootDir: manifest.rootDir,
562
+ label: "agent"
563
+ });
564
+ }
565
+ function isAgentManifestLike(input) {
566
+ return typeof input === "object" && input !== null && input.version === 1 && typeof input.kind === "string" && Array.isArray(input.files) && typeof input.commands === "object";
567
+ }
568
+ //#endregion
569
+ //#region src/common/argv.ts
570
+ function formatArgv(argv) {
571
+ return argv.map(formatArg).join(" ");
572
+ }
573
+ function formatArg(arg) {
574
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(arg)) return arg;
575
+ return `'${arg.replace(/'/g, "'\\''")}'`;
576
+ }
577
+ //#endregion
578
+ //#region src/agent/run_record.ts
579
+ const AGENT_RUN_RECORD_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-run.schema.json";
580
+ const agentRunModeSchema = z.enum(["live", "replay"]);
581
+ const agentRecordedStepSchema = z.object({
582
+ index: z.number().int().nonnegative(),
583
+ type: z.string().min(1),
584
+ label: z.string(),
585
+ durationMs: z.number().int().nonnegative(),
586
+ ok: z.boolean(),
587
+ error: z.string().optional()
588
+ }).strict();
589
+ const agentRunArtifactSchema = z.object({
590
+ name: z.string().min(1),
591
+ viewport: z.string().min(1),
592
+ kind: z.enum([
593
+ "terminal",
594
+ "dom",
595
+ "screenshot"
596
+ ]),
597
+ path: z.string().min(1),
598
+ baselinePath: z.string().min(1).optional(),
599
+ diffPath: z.string().min(1).optional(),
600
+ hash: z.string().min(1).optional(),
601
+ ok: z.boolean(),
602
+ error: z.string().optional()
603
+ }).strict();
604
+ const agentCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
605
+ const agentRunRecordCommandsSchema = z.object({
606
+ replay: agentCommandSchema,
607
+ updateSnapshots: agentCommandSchema
608
+ }).strict();
609
+ const agentRunRecordSchema = z.object({
610
+ $schema: z.string().optional(),
611
+ version: z.literal(1),
612
+ name: z.string().min(1),
613
+ ok: z.boolean(),
614
+ startedAt: z.string().min(1),
615
+ durationMs: z.number().int().nonnegative(),
616
+ mode: agentRunModeSchema,
617
+ spec: agentFlowSpecSchema.optional(),
618
+ flowPath: z.string().min(1).optional(),
619
+ artifactsDir: z.string().min(1),
620
+ snapshotDir: z.string().min(1),
621
+ reportPath: z.string().min(1),
622
+ cassettePath: z.string().min(1).optional(),
623
+ cassetteFrameCount: z.number().int().nonnegative(),
624
+ replayCommand: z.string().min(1),
625
+ commands: agentRunRecordCommandsSchema,
626
+ steps: z.array(agentRecordedStepSchema),
627
+ artifacts: z.array(agentRunArtifactSchema),
628
+ errors: z.array(z.string())
629
+ }).strict().superRefine((record, ctx) => {
630
+ if (!record.cassettePath && !record.flowPath && !record.spec) ctx.addIssue({
631
+ code: z.ZodIssueCode.custom,
632
+ message: "agent run record requires cassettePath, flowPath, or spec"
633
+ });
634
+ const replayCommand = formatArgv(record.commands.replay.argv);
635
+ if (record.replayCommand !== replayCommand) ctx.addIssue({
636
+ code: z.ZodIssueCode.custom,
637
+ path: ["replayCommand"],
638
+ message: "replayCommand must match commands.replay.argv"
639
+ });
640
+ if (!isReplayArgv(record.commands.replay.argv)) ctx.addIssue({
641
+ code: z.ZodIssueCode.custom,
642
+ path: [
643
+ "commands",
644
+ "replay",
645
+ "argv"
646
+ ],
647
+ message: "replay argv must be a ptywright agent replay command"
648
+ });
649
+ const expectedUpdateSnapshots = [...record.commands.replay.argv, "--update-snapshots"];
650
+ if (!sameArgv(record.commands.updateSnapshots.argv, expectedUpdateSnapshots)) ctx.addIssue({
651
+ code: z.ZodIssueCode.custom,
652
+ path: [
653
+ "commands",
654
+ "updateSnapshots",
655
+ "argv"
656
+ ],
657
+ message: "updateSnapshots argv must extend commands.replay.argv"
658
+ });
659
+ });
660
+ function normalizeAgentRunRecord(input) {
661
+ try {
662
+ const parsed = agentRunRecordSchema.parse(input);
663
+ return {
664
+ ...parsed,
665
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json",
666
+ spec: parsed.spec ? normalizeAgentFlowSpec(parsed.spec) : void 0
667
+ };
668
+ } catch (error) {
669
+ if (error instanceof z.ZodError) throw new Error(`invalid agent run record: ${formatZodIssues(error)}`);
670
+ throw error;
671
+ }
700
672
  }
701
- function extractUrlFromOutput(output, regexSource) {
702
- if (!regexSource) return output.match(DEFAULT_URL_REGEX)?.[0] ?? null;
703
- const match = output.match(new RegExp(regexSource, "m"));
704
- return match?.[1] ?? match?.[0] ?? null;
673
+ function formatAgentArgv(argv) {
674
+ return formatArgv(argv);
705
675
  }
706
- function formatBrowserLaunchCommand(command) {
707
- return [command.file, ...command.args].join(" ");
676
+ function isReplayArgv(argv) {
677
+ return argv.length >= 4 && argv[0] === "ptywright" && argv[1] === "agent" && argv[2] === "replay";
708
678
  }
709
- async function closeChild(child) {
710
- if (child.exitCode !== null || child.signalCode !== null) return;
711
- await new Promise((resolveClose) => {
712
- const timer = setTimeout(() => {
713
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
714
- resolveClose();
715
- }, 2e3);
716
- child.once("exit", () => {
717
- clearTimeout(timer);
718
- resolveClose();
719
- });
720
- child.kill("SIGTERM");
679
+ function readAgentRunRecordPath(path) {
680
+ return normalizeAgentRunRecord(JSON.parse(readFileSync(path, "utf8")));
681
+ }
682
+ function writeAgentRunRecordPath(path, record) {
683
+ const normalized = normalizeAgentRunRecord({
684
+ ...record,
685
+ $schema: record.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json"
721
686
  });
687
+ writeFileSync(path, JSON.stringify(normalized, null, 2) + "\n", "utf8");
722
688
  }
723
- //#endregion
724
- //#region src/agent/launch.ts
725
- function buildAgentLaunchCommand(launch, options = {}) {
726
- if (resolveAgentLaunchMode(launch) === "url") return null;
727
- return buildCommandLaunchCommand(launch, options);
689
+ function isAgentRunRecordLike(input) {
690
+ if (typeof input !== "object" || input === null) return false;
691
+ const candidate = input;
692
+ return candidate.version === 1 && ("cassettePath" in candidate || "flowPath" in candidate || "spec" in candidate);
728
693
  }
729
- async function resolveAgentLaunchTarget(launch, options = {}) {
730
- const mode = resolveAgentLaunchMode(launch);
731
- if (mode === "url") return {
732
- mode,
733
- url: launch.url,
734
- session: null
735
- };
736
- const session = await launchBrowserSessionFromCommand(buildCommandLaunchCommand(launch, options));
694
+ //#endregion
695
+ //#region src/agent/spec_loader.ts
696
+ async function loadAgentSpec(specPath) {
697
+ const resolved = resolve(process.cwd(), specPath);
698
+ if (resolved.endsWith(".json")) {
699
+ const raw = JSON.parse(readFileSync(resolved, "utf8"));
700
+ return {
701
+ spec: normalizeAgentFlowSpec(raw),
702
+ raw,
703
+ path: resolved
704
+ };
705
+ }
706
+ const mod = await import(`${pathToFileURL(resolved).href}?t=${Date.now()}`);
707
+ const raw = mod.default ?? mod.spec;
737
708
  return {
738
- mode,
739
- url: session.url,
740
- session
709
+ spec: normalizeAgentFlowSpec(raw),
710
+ raw,
711
+ path: resolved
741
712
  };
742
713
  }
743
- function formatAgentLaunchCommand(launch) {
744
- const command = buildAgentLaunchCommand(launch);
745
- return command ? formatBrowserLaunchCommand(command) : "launch.mode=url";
746
- }
747
714
  //#endregion
748
715
  //#region src/agent/presets.ts
749
716
  const COMMON_AGENT_MASKS = [
@@ -777,7 +744,7 @@ const COMMON_AGENT_MASKS = [
777
744
  ];
778
745
  const FLAVOR_MASKS = {
779
746
  codex: [{
780
- regex: "\\b(?:gpt|o)[A-Za-z0-9._:-]+\\b",
747
+ regex: "\\b(?:gpt-[A-Za-z0-9._:-]+|o[0-9][A-Za-z0-9._:-]*)\\b",
781
748
  flags: "gi",
782
749
  replacement: "<model>"
783
750
  }, {
@@ -867,157 +834,982 @@ function createAgentTemplateSpec(flavor) {
867
834
  };
868
835
  }
869
836
  //#endregion
870
- //#region src/common/argv.ts
871
- function formatArgv(argv) {
872
- return argv.map(formatArg).join(" ");
837
+ //#region src/agent/browser.ts
838
+ async function launchAgentBrowser(args) {
839
+ let lastError;
840
+ for (let attempt = 0; attempt < 5; attempt += 1) try {
841
+ const browser = await chromium.launch({ headless: args.headless });
842
+ await verifyBrowserLaunch(browser);
843
+ return browser;
844
+ } catch (error) {
845
+ lastError = error;
846
+ if (!isTransientBrowserLaunchError(error) || attempt === 4) throw error;
847
+ await new Promise((resolveRetry) => setTimeout(resolveRetry, 250 * (attempt + 1)));
848
+ }
849
+ throw lastError;
850
+ }
851
+ async function verifyBrowserLaunch(browser) {
852
+ const context = await browser.newContext();
853
+ try {
854
+ const page = await context.newPage();
855
+ await page.goto("data:text/html,<title>ptywright</title>", { waitUntil: "load" });
856
+ await page.close();
857
+ } catch (error) {
858
+ await browser.close();
859
+ throw error;
860
+ } finally {
861
+ await context.close().catch(() => void 0);
862
+ }
863
+ }
864
+ function isTransientBrowserLaunchError(error) {
865
+ const fields = errorFields(error);
866
+ 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";
867
+ }
868
+ function errorFields(error) {
869
+ const record = typeof error === "object" && error !== null ? error : {};
870
+ return {
871
+ message: error instanceof Error ? error.message : String(error),
872
+ code: typeof record.code === "string" ? record.code : "",
873
+ syscall: typeof record.syscall === "string" ? record.syscall : ""
874
+ };
875
+ }
876
+ //#endregion
877
+ //#region src/agent/aitty_report_assets.ts
878
+ function prepareAittyReportAssets(context) {
879
+ const sources = resolveAittyReportAssetSources(context);
880
+ if (!existsSync(sources.scriptSource) || !existsSync(sources.styleSource)) throw new Error("@aitty/snapshot report assets are missing. Run the package build before generating reports.");
881
+ const assetDir = join(context.artifactsDir, "assets");
882
+ const scriptPath = join(assetDir, "aitty-web-component.js");
883
+ const stylePath = join(assetDir, "aitty-terminal.css");
884
+ mkdirSync(assetDir, { recursive: true });
885
+ copyFileSync(sources.scriptSource, scriptPath);
886
+ copyFileSync(sources.styleSource, stylePath);
887
+ return {
888
+ scriptPath,
889
+ scriptType: sources.scriptType,
890
+ stylePath
891
+ };
892
+ }
893
+ function resolveAittyReportAssetSources(context) {
894
+ for (const resolverBase of resolveAittyReportResolverBases(context)) {
895
+ const sources = tryResolveAittyReportAssetSources(createRequire(resolverBase));
896
+ if (sources) return sources;
897
+ }
898
+ const fallback = tryResolveAittyReportAssetSources(createRequire(import.meta.url));
899
+ if (fallback) return fallback;
900
+ throw new Error("@aitty/snapshot report assets are missing. Install @aitty/snapshot or run the package build before generating reports.");
901
+ }
902
+ function resolveAittyReportResolverBases(context) {
903
+ const candidates = [
904
+ findNearestPackageJson(dirname(resolve(context.flowPath))),
905
+ findNearestPackageJson(dirname(resolve(context.reportPath))),
906
+ findNearestPackageJson(dirname(resolve(context.artifactsDir))),
907
+ findNearestPackageJson(process.cwd())
908
+ ].filter((path) => Boolean(path));
909
+ return Array.from(new Set(candidates));
910
+ }
911
+ function findNearestPackageJson(startDir) {
912
+ let currentDir = resolve(startDir);
913
+ while (true) {
914
+ const packagePath = join(currentDir, "package.json");
915
+ if (existsSync(packagePath)) return packagePath;
916
+ const parentDir = dirname(currentDir);
917
+ if (parentDir === currentDir) return null;
918
+ currentDir = parentDir;
919
+ }
920
+ }
921
+ function tryResolveAittyReportAssetSources(resolver) {
922
+ return tryResolveAittyPackageAssetSources(resolver, "@aitty/snapshot") ?? tryResolveAittyPackageAssetSources(resolver, "@aitty/browser");
923
+ }
924
+ function tryResolveAittyPackageAssetSources(resolver, packageName) {
925
+ let scriptSource;
926
+ let scriptType = "classic";
927
+ let styleSource;
928
+ try {
929
+ scriptSource = resolver.resolve(`${packageName}/web-component.global.js`);
930
+ } catch {
931
+ try {
932
+ scriptSource = resolver.resolve(`${packageName}/web-component.js`);
933
+ scriptType = "module";
934
+ } catch {
935
+ return null;
936
+ }
937
+ }
938
+ try {
939
+ styleSource = resolver.resolve(`${packageName}/style.css`);
940
+ } catch {
941
+ return null;
942
+ }
943
+ return {
944
+ scriptSource,
945
+ scriptType,
946
+ styleSource
947
+ };
948
+ }
949
+ //#endregion
950
+ //#region src/agent/report_paths.ts
951
+ function relativeHref(fromPath, targetPath, artifactsDir) {
952
+ if (targetPath.startsWith(artifactsDir)) return relative(dirname(fromPath), targetPath).replaceAll("\\", "/") || ".";
953
+ return targetPath;
954
+ }
955
+ //#endregion
956
+ //#region src/agent/report_aitty_preview.ts
957
+ function resolveAittyPreviewAssets(previewPath, assets, artifactsDir) {
958
+ return {
959
+ scriptHref: relativeHref(previewPath, assets.scriptPath, artifactsDir),
960
+ scriptType: assets.scriptType,
961
+ styleHref: relativeHref(previewPath, assets.stylePath, artifactsDir)
962
+ };
963
+ }
964
+ function renderAittyPreviewAssetTags(assets) {
965
+ return ` <link rel="stylesheet" href="${escapeAttribute(assets.styleHref)}" />
966
+ <script${assets.scriptType === "module" ? " type=\"module\"" : ""} src="${escapeAttribute(assets.scriptHref)}"><\/script>
967
+ `;
968
+ }
969
+ function renderAittyPreviewBody(args) {
970
+ const { snapshot, snapshotLayout, viewOptions } = args;
971
+ return ` <aitty-snapshot
972
+ cols="${snapshotLayout.cols}"
973
+ rows="${snapshotLayout.rows}"
974
+ screen-mode="${escapeAttribute(viewOptions.screenMode)}"
975
+ theme="${escapeAttribute(viewOptions.theme)}"
976
+ font-size="${snapshotLayout.fontSize}"
977
+ line-height="${snapshotLayout.lineHeight}"
978
+ >${snapshot}</aitty-snapshot>`;
979
+ }
980
+ function renderAittyPreviewCss(viewOptions) {
981
+ return ` :root {
982
+ color-scheme: ${viewOptions.theme};
983
+ }
984
+ html,
985
+ body {
986
+ width: 100%;
987
+ height: 100%;
988
+ margin: 0;
989
+ background: var(--theme-term-bg, Canvas);
990
+ }
991
+ aitty-snapshot {
992
+ display: block;
993
+ width: 100%;
994
+ height: 100%;
995
+ }`;
996
+ }
997
+ //#endregion
998
+ //#region src/agent/report_artifact_paths.ts
999
+ function artifactViewerPath(artifact) {
1000
+ if (artifact.kind === "terminal") return artifact.path.endsWith(".terminal.txt") ? artifact.path.replace(/\.terminal\.txt$/, ".terminal.viewer.html") : `${artifact.path}.viewer.html`;
1001
+ if (artifact.kind === "dom") return artifact.path.endsWith(".dom.html") ? artifact.path.replace(/\.dom\.html$/, ".dom.viewer.html") : `${artifact.path}.viewer.html`;
1002
+ return null;
1003
+ }
1004
+ function artifactDomPreviewPath(artifact) {
1005
+ return artifact.path.endsWith(".dom.html") ? artifact.path.replace(/\.dom\.html$/, ".dom.preview.html") : `${artifact.path}.preview.html`;
1006
+ }
1007
+ function artifactSnapshotKey(artifact) {
1008
+ return `${artifact.viewport}\u0000${artifact.name}`;
1009
+ }
1010
+ //#endregion
1011
+ //#region src/agent/report_dom_artifact_viewer.ts
1012
+ function resolveReportDomPreview(path) {
1013
+ return path ? { path } : null;
1014
+ }
1015
+ function renderDomArtifactViewer(args) {
1016
+ const { artifactsDir, mobile, preview, viewerPath, viewOptions } = args;
1017
+ const src = relativeHref(viewerPath, preview.path, artifactsDir);
1018
+ 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>`;
1019
+ }
1020
+ function renderDomArtifactViewerFragment(args) {
1021
+ return {
1022
+ css: "",
1023
+ html: renderDomArtifactViewer(args),
1024
+ script: ""
1025
+ };
1026
+ }
1027
+ //#endregion
1028
+ //#region src/agent/report_raw_ansi_text.ts
1029
+ function renderRawAnsiTextHtml(input) {
1030
+ const out = [];
1031
+ let style = {};
1032
+ let segmentStart = 0;
1033
+ let index = 0;
1034
+ while (index < input.length) {
1035
+ if (!isCsiStart(input, index)) {
1036
+ index += 1;
1037
+ continue;
1038
+ }
1039
+ const end = findCsiEnd(input, index + 2);
1040
+ if (end === -1) {
1041
+ index += 1;
1042
+ continue;
1043
+ }
1044
+ out.push(renderAnsiTextSegment(input.slice(segmentStart, index), style));
1045
+ if (input[end] === "m") style = applySgrCodes(style, parseSgrCodes(input.slice(index + 2, end)));
1046
+ index = end + 1;
1047
+ segmentStart = index;
1048
+ }
1049
+ out.push(renderAnsiTextSegment(input.slice(segmentStart), style));
1050
+ return out.join("");
1051
+ }
1052
+ function isCsiStart(input, index) {
1053
+ return input.charCodeAt(index) === 27 && input[index + 1] === "[";
1054
+ }
1055
+ function findCsiEnd(input, start) {
1056
+ for (let index = start; index < input.length; index += 1) {
1057
+ const code = input.charCodeAt(index);
1058
+ if (code >= 64 && code <= 126) return index;
1059
+ }
1060
+ return -1;
1061
+ }
1062
+ function parseSgrCodes(input) {
1063
+ if (!input) return [0];
1064
+ return input.split(";").map((value) => {
1065
+ const parsed = Number.parseInt(value, 10);
1066
+ return Number.isFinite(parsed) ? parsed : 0;
1067
+ });
1068
+ }
1069
+ function applySgrCodes(current, codes) {
1070
+ const next = { ...current };
1071
+ for (let index = 0; index < codes.length; index += 1) {
1072
+ const code = codes[index] ?? 0;
1073
+ if (code === 0) {
1074
+ resetStyle(next);
1075
+ continue;
1076
+ }
1077
+ if (code === 1) next.bold = true;
1078
+ else if (code === 2) next.dim = true;
1079
+ else if (code === 3) next.italic = true;
1080
+ else if (code === 4) next.underline = true;
1081
+ else if (code === 7) next.inverse = true;
1082
+ else if (code === 9) next.strikethrough = true;
1083
+ else if (code === 22) {
1084
+ next.bold = false;
1085
+ next.dim = false;
1086
+ } else if (code === 23) next.italic = false;
1087
+ else if (code === 24) next.underline = false;
1088
+ else if (code === 27) next.inverse = false;
1089
+ else if (code === 29) next.strikethrough = false;
1090
+ else if (code === 39) next.fg = void 0;
1091
+ else if (code === 49) next.bg = void 0;
1092
+ else if (code >= 30 && code <= 37) next.fg = ansiPaletteColor(code - 30);
1093
+ else if (code >= 90 && code <= 97) next.fg = ansiPaletteColor(code - 90 + 8);
1094
+ else if (code >= 40 && code <= 47) next.bg = ansiPaletteColor(code - 40);
1095
+ else if (code >= 100 && code <= 107) next.bg = ansiPaletteColor(code - 100 + 8);
1096
+ else if ((code === 38 || code === 48) && codes[index + 1] === 5) {
1097
+ const color = ansiPaletteColor(codes[index + 2] ?? 0);
1098
+ if (code === 38) next.fg = color;
1099
+ else next.bg = color;
1100
+ index += 2;
1101
+ } else if ((code === 38 || code === 48) && codes[index + 1] === 2) {
1102
+ const color = `rgb(${clampColor(codes[index + 2] ?? 0)} ${clampColor(codes[index + 3] ?? 0)} ${clampColor(codes[index + 4] ?? 0)})`;
1103
+ if (code === 38) next.fg = color;
1104
+ else next.bg = color;
1105
+ index += 4;
1106
+ }
1107
+ }
1108
+ return next;
1109
+ }
1110
+ function resetStyle(style) {
1111
+ style.fg = void 0;
1112
+ style.bg = void 0;
1113
+ style.bold = false;
1114
+ style.dim = false;
1115
+ style.italic = false;
1116
+ style.underline = false;
1117
+ style.inverse = false;
1118
+ style.strikethrough = false;
1119
+ }
1120
+ function renderAnsiTextSegment(text, style) {
1121
+ if (!text) return "";
1122
+ const safeText = escapeHtml(text);
1123
+ const css = ansiStyleToCss(style);
1124
+ return css ? `<span style="${escapeAttribute(css)}">${safeText}</span>` : safeText;
1125
+ }
1126
+ function ansiStyleToCss(style) {
1127
+ const decls = [];
1128
+ const fg = style.inverse ? style.bg : style.fg;
1129
+ const bg = style.inverse ? style.fg : style.bg;
1130
+ if (fg) decls.push(`color: ${fg}`);
1131
+ if (bg) decls.push(`background-color: ${bg}`);
1132
+ if (style.bold) decls.push("font-weight: 700");
1133
+ if (style.italic) decls.push("font-style: italic");
1134
+ if (style.dim) decls.push("opacity: 0.72");
1135
+ const decorations = [];
1136
+ if (style.underline) decorations.push("underline");
1137
+ if (style.strikethrough) decorations.push("line-through");
1138
+ if (decorations.length > 0) decls.push(`text-decoration: ${decorations.join(" ")}`);
1139
+ return decls.join("; ");
1140
+ }
1141
+ function ansiPaletteColor(index) {
1142
+ const table16 = [
1143
+ "#151b23",
1144
+ "#ff7b72",
1145
+ "#7ee787",
1146
+ "#f2cc60",
1147
+ "#79c0ff",
1148
+ "#d2a8ff",
1149
+ "#70e1e8",
1150
+ "#e6edf7",
1151
+ "#6e7681",
1152
+ "#ffa198",
1153
+ "#aff5b4",
1154
+ "#ffdf80",
1155
+ "#a5d6ff",
1156
+ "#e2c5ff",
1157
+ "#96f0f5",
1158
+ "#ffffff"
1159
+ ];
1160
+ const normalized = clampColor(index);
1161
+ if (normalized < table16.length) return table16[normalized] ?? "#e6edf7";
1162
+ if (normalized <= 231) {
1163
+ const values = [
1164
+ 0,
1165
+ 95,
1166
+ 135,
1167
+ 175,
1168
+ 215,
1169
+ 255
1170
+ ];
1171
+ const offset = normalized - 16;
1172
+ return `rgb(${values[Math.trunc(offset / 36) % 6] ?? 0} ${values[Math.trunc(offset / 6) % 6] ?? 0} ${values[offset % 6] ?? 0})`;
1173
+ }
1174
+ const gray = clampColor(8 + (normalized - 232) * 10);
1175
+ return `rgb(${gray} ${gray} ${gray})`;
1176
+ }
1177
+ function clampColor(value) {
1178
+ if (!Number.isFinite(value)) return 0;
1179
+ return Math.max(0, Math.min(255, Math.trunc(value)));
1180
+ }
1181
+ //#endregion
1182
+ //#region src/agent/report_viewer_pan.ts
1183
+ function renderReportViewportPanCss() {
1184
+ return ` [data-ptywright-report-pan="true"] {
1185
+ cursor: grab;
1186
+ }
1187
+ [data-ptywright-report-panning="true"] {
1188
+ cursor: grabbing;
1189
+ }`;
1190
+ }
1191
+ function renderReportViewportPanScript(body) {
1192
+ return ` (() => {
1193
+ const isHtmlElement = (value) => {
1194
+ return Boolean(
1195
+ value &&
1196
+ value.nodeType === 1 &&
1197
+ value.dataset &&
1198
+ value.style &&
1199
+ typeof value.addEventListener === "function"
1200
+ );
1201
+ };
1202
+
1203
+ const scrollToBottom = (element) => {
1204
+ element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
1205
+ };
1206
+
1207
+ const enableViewportPan = (element) => {
1208
+ if (!isHtmlElement(element) || element.dataset.ptywrightReportPan === "true") {
1209
+ return;
1210
+ }
1211
+
1212
+ element.dataset.ptywrightReportPan = "true";
1213
+ let activePointerId = null;
1214
+ let startX = 0;
1215
+ let startY = 0;
1216
+ let startScrollLeft = 0;
1217
+ let startScrollTop = 0;
1218
+ let moved = false;
1219
+
1220
+ element.addEventListener("pointerdown", (event) => {
1221
+ if (event.button !== 0) return;
1222
+ activePointerId = event.pointerId;
1223
+ startX = event.clientX;
1224
+ startY = event.clientY;
1225
+ startScrollLeft = element.scrollLeft;
1226
+ startScrollTop = element.scrollTop;
1227
+ moved = false;
1228
+ element.dataset.ptywrightReportPanning = "true";
1229
+ element.setPointerCapture?.(event.pointerId);
1230
+ });
1231
+
1232
+ element.addEventListener("pointermove", (event) => {
1233
+ if (activePointerId !== event.pointerId) return;
1234
+ const deltaX = event.clientX - startX;
1235
+ const deltaY = event.clientY - startY;
1236
+ if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
1237
+ moved = true;
1238
+ }
1239
+ element.scrollLeft = startScrollLeft - deltaX;
1240
+ element.scrollTop = startScrollTop - deltaY;
1241
+ if (moved) event.preventDefault();
1242
+ });
1243
+
1244
+ const finish = (event) => {
1245
+ if (activePointerId !== event.pointerId) return;
1246
+ activePointerId = null;
1247
+ element.dataset.ptywrightReportPanning = "false";
1248
+ element.releasePointerCapture?.(event.pointerId);
1249
+ };
1250
+
1251
+ element.addEventListener("pointerup", finish);
1252
+ element.addEventListener("pointercancel", finish);
1253
+ };
1254
+ ${body}
1255
+ })();`;
1256
+ }
1257
+ //#endregion
1258
+ //#region src/agent/report_raw_artifact_viewer.ts
1259
+ function renderRawArtifactViewer(content, mobile, viewOptions) {
1260
+ 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>`;
1261
+ }
1262
+ function renderRawArtifactViewerCss() {
1263
+ return ` .raw-artifact-text {
1264
+ min-width: 100%;
1265
+ width: max-content;
1266
+ min-height: 100%;
1267
+ margin: 0;
1268
+ background:
1269
+ linear-gradient(180deg, color-mix(in srgb, #162033 92%, black), #0c111d);
1270
+ color: #e6edf7;
1271
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
1272
+ font-size: 13px;
1273
+ line-height: 1.45;
1274
+ overflow: visible;
1275
+ padding: 12px;
1276
+ white-space: pre;
1277
+ }
1278
+ .viewer-page[data-theme="light"] .raw-artifact-text {
1279
+ background: #ffffff;
1280
+ color: #4c4f69;
1281
+ }
1282
+ ${renderReportViewportPanCss()}`;
1283
+ }
1284
+ function renderRawArtifactViewerScript() {
1285
+ return renderReportViewportPanScript(`
1286
+ document.querySelectorAll(".raw-artifact-viewport").forEach((viewport) => {
1287
+ enableViewportPan(viewport);
1288
+ if (viewport.getAttribute("data-screen-mode") === "termvision") {
1289
+ requestAnimationFrame(() => scrollToBottom(viewport));
1290
+ }
1291
+ });
1292
+ `);
1293
+ }
1294
+ function renderRawArtifactViewerFragment(args) {
1295
+ const { content, mobile, viewOptions } = args;
1296
+ return {
1297
+ css: renderRawArtifactViewerCss(),
1298
+ html: renderRawArtifactViewer(content, mobile, viewOptions),
1299
+ script: renderRawArtifactViewerScript()
1300
+ };
1301
+ }
1302
+ //#endregion
1303
+ //#region src/agent/report_artifact_viewer_fragment.ts
1304
+ function renderArtifactViewerFragment(args) {
1305
+ const { artifactsDir, content, domViewerPreview, mobile, viewerPath, viewOptions } = args;
1306
+ if (domViewerPreview) return renderDomArtifactViewerFragment({
1307
+ artifactsDir,
1308
+ mobile,
1309
+ preview: domViewerPreview,
1310
+ viewerPath,
1311
+ viewOptions
1312
+ });
1313
+ return renderRawArtifactViewerFragment({
1314
+ content,
1315
+ mobile,
1316
+ viewOptions
1317
+ });
1318
+ }
1319
+ //#endregion
1320
+ //#region src/agent/report_artifact_viewer_shell_css.ts
1321
+ function renderArtifactViewerShellCss() {
1322
+ return ` :root {
1323
+ color-scheme: dark;
1324
+ --bg: #080d16;
1325
+ --panel: #0c111d;
1326
+ --line: rgba(148, 163, 184, 0.26);
1327
+ --ink: #e6edf7;
1328
+ --muted: #91a0b8;
1329
+ --focus: #79c0ff;
1330
+ font-family:
1331
+ ui-sans-serif,
1332
+ system-ui,
1333
+ -apple-system,
1334
+ BlinkMacSystemFont,
1335
+ "Segoe UI",
1336
+ sans-serif;
1337
+ }
1338
+ * {
1339
+ box-sizing: border-box;
1340
+ }
1341
+ html,
1342
+ body {
1343
+ width: 100%;
1344
+ height: 100%;
1345
+ margin: 0;
1346
+ overflow: hidden;
1347
+ background: var(--bg);
1348
+ color: var(--ink);
1349
+ }
1350
+ .viewer-page {
1351
+ display: grid;
1352
+ grid-template-rows: auto minmax(0, 1fr);
1353
+ width: 100%;
1354
+ height: 100dvh;
1355
+ min-width: 0;
1356
+ min-height: 0;
1357
+ }
1358
+ .viewer-toolbar {
1359
+ display: flex;
1360
+ flex-wrap: wrap;
1361
+ gap: 8px;
1362
+ align-items: center;
1363
+ min-width: 0;
1364
+ border-bottom: 1px solid var(--line);
1365
+ background: color-mix(in srgb, var(--panel) 88%, black);
1366
+ padding: 10px 12px;
1367
+ }
1368
+ .viewer-title {
1369
+ min-width: min(100%, 220px);
1370
+ margin-right: auto;
1371
+ overflow-wrap: anywhere;
1372
+ font-size: 14px;
1373
+ font-weight: 700;
1374
+ }
1375
+ .viewer-link,
1376
+ .viewer-pill {
1377
+ display: inline-flex;
1378
+ min-height: 30px;
1379
+ align-items: center;
1380
+ border: 1px solid var(--line);
1381
+ border-radius: 999px;
1382
+ padding: 0 10px;
1383
+ color: var(--muted);
1384
+ font-size: 12px;
1385
+ text-decoration: none;
1386
+ }
1387
+ .viewer-link {
1388
+ color: var(--focus);
1389
+ font-weight: 700;
1390
+ }
1391
+ .viewer-stage {
1392
+ display: grid;
1393
+ grid-template-columns: max-content;
1394
+ grid-template-rows: max-content;
1395
+ min-width: 0;
1396
+ min-height: 0;
1397
+ justify-content: start;
1398
+ align-content: start;
1399
+ overflow: auto;
1400
+ background:
1401
+ radial-gradient(circle at top left, rgba(121, 192, 255, 0.1), transparent 32%),
1402
+ #060a13;
1403
+ padding: 14px;
1404
+ }
1405
+ .viewer-viewport {
1406
+ width: var(--config-viewport-width);
1407
+ height: var(--config-viewport-height);
1408
+ max-width: none;
1409
+ max-height: none;
1410
+ overflow: auto;
1411
+ overscroll-behavior: contain;
1412
+ border: 0;
1413
+ border-radius: 8px;
1414
+ background: #0c111d;
1415
+ outline: 1px solid var(--line);
1416
+ box-shadow: 0 18px 52px rgba(0, 0, 0, 0.34);
1417
+ }
1418
+ .viewer-viewport[data-mobile="true"] {
1419
+ width: var(--config-viewport-width);
1420
+ }
1421
+ .dom-viewport {
1422
+ overflow: hidden;
1423
+ }
1424
+ .dom-viewer-frame {
1425
+ display: block;
1426
+ width: 100%;
1427
+ height: 100%;
1428
+ border: 0;
1429
+ background: #0c111d;
1430
+ }
1431
+ .viewer-page[data-theme="light"] {
1432
+ --bg: #f8fafc;
1433
+ --panel: #ffffff;
1434
+ --line: rgba(15, 23, 42, 0.14);
1435
+ --ink: #0f172a;
1436
+ --muted: #64748b;
1437
+ --focus: #1e66f5;
1438
+ }
1439
+ .viewer-page[data-theme="light"] .viewer-stage {
1440
+ background: #f8fafc;
1441
+ }
1442
+ .viewer-page[data-theme="light"] .viewer-viewport,
1443
+ .viewer-page[data-theme="light"] .dom-viewer-frame {
1444
+ background: #ffffff;
1445
+ }
1446
+ @media (max-width: 720px) {
1447
+ .viewer-toolbar {
1448
+ padding: 8px;
1449
+ }
1450
+ .viewer-title {
1451
+ flex-basis: 100%;
1452
+ order: -1;
1453
+ }
1454
+ .viewer-stage {
1455
+ padding: 0;
1456
+ }
1457
+ .viewer-viewport {
1458
+ width: var(--config-viewport-width);
1459
+ height: var(--config-viewport-height);
1460
+ border-radius: 0;
1461
+ }
1462
+ }`;
1463
+ }
1464
+ //#endregion
1465
+ //#region src/agent/report_artifact_viewer_shell.ts
1466
+ function renderArtifactViewerShellHtml(args) {
1467
+ const { artifact, artifactsDir, contentFragment, mobile, reportPath, viewOptions, viewerPath } = args;
1468
+ const title = `${artifact.viewport} ${artifact.name} ${artifact.kind}`;
1469
+ const viewportStyle = renderConfiguredViewportStyle(args.viewport);
1470
+ const viewportLabel = args.viewport ? `${args.viewport.name} ${args.viewport.width}x${args.viewport.height}` : `${artifact.viewport} viewport`;
1471
+ const backHref = relativeHref(viewerPath, reportPath, artifactsDir);
1472
+ const rawHref = relativeHref(viewerPath, artifact.path, artifactsDir);
1473
+ const baselineHref = artifact.baselinePath ? relativeHref(viewerPath, artifact.baselinePath, artifactsDir) : "";
1474
+ const diffHref = artifact.diffPath ? relativeHref(viewerPath, artifact.diffPath, artifactsDir) : "";
1475
+ const scriptTag = contentFragment.script ? ` <script>
1476
+ ${contentFragment.script}
1477
+ <\/script>
1478
+ ` : "";
1479
+ return `<!doctype html>
1480
+ <html lang="en" data-theme="${escapeAttribute(viewOptions.theme)}">
1481
+ <head>
1482
+ <meta charset="utf-8" />
1483
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1484
+ <title>${escapeHtml(title)}</title>
1485
+ <style>
1486
+ ${renderArtifactViewerShellCss()}
1487
+ ${contentFragment.css}
1488
+ </style>
1489
+ </head>
1490
+ <body>
1491
+ <main class="viewer-page" data-report-screen-mode="${escapeAttribute(viewOptions.screenMode)}" data-theme="${escapeAttribute(viewOptions.theme)}" style="${escapeAttribute(viewportStyle)}">
1492
+ <header class="viewer-toolbar">
1493
+ <a class="viewer-link" href="${escapeAttribute(backHref)}">Report</a>
1494
+ <span class="viewer-title">${escapeHtml(title)}</span>
1495
+ <span class="viewer-pill">${escapeHtml(viewportLabel)}</span>
1496
+ <span class="viewer-pill">${escapeHtml(viewOptions.screenMode)}</span>
1497
+ <a class="viewer-link" href="${escapeAttribute(rawHref)}">Raw</a>
1498
+ ${baselineHref ? `<a class="viewer-link" href="${escapeAttribute(baselineHref)}">Baseline</a>` : ""}
1499
+ ${diffHref ? `<a class="viewer-link" href="${escapeAttribute(diffHref)}">Diff</a>` : ""}
1500
+ </header>
1501
+ <section class="viewer-stage" data-mobile="${mobile ? "true" : "false"}">
1502
+ ${contentFragment.html}
1503
+ </section>
1504
+ </main>
1505
+ ${scriptTag}
1506
+ </body>
1507
+ </html>`;
873
1508
  }
874
- function formatArg(arg) {
875
- if (/^[A-Za-z0-9_./:=@+-]+$/.test(arg)) return arg;
876
- return `'${arg.replace(/'/g, "'\\''")}'`;
1509
+ function renderConfiguredViewportStyle(viewport) {
1510
+ if (!viewport) return "--config-viewport-width: 1280px; --config-viewport-height: 760px;";
1511
+ return `--config-viewport-width: ${viewport.width}px; --config-viewport-height: ${viewport.height}px;`;
877
1512
  }
878
1513
  //#endregion
879
- //#region src/agent/run_record.ts
880
- const AGENT_RUN_RECORD_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-run.schema.json";
881
- const agentRunModeSchema = z.enum(["live", "replay"]);
882
- const agentRecordedStepSchema = z.object({
883
- index: z.number().int().nonnegative(),
884
- type: z.string().min(1),
885
- label: z.string(),
886
- durationMs: z.number().int().nonnegative(),
887
- ok: z.boolean(),
888
- error: z.string().optional()
889
- }).strict();
890
- const agentRunArtifactSchema = z.object({
891
- name: z.string().min(1),
892
- viewport: z.string().min(1),
893
- kind: z.enum([
894
- "terminal",
895
- "dom",
896
- "screenshot"
897
- ]),
898
- path: z.string().min(1),
899
- baselinePath: z.string().min(1).optional(),
900
- diffPath: z.string().min(1).optional(),
901
- hash: z.string().min(1).optional(),
902
- ok: z.boolean(),
903
- error: z.string().optional()
904
- }).strict();
905
- const agentCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
906
- const agentRunRecordCommandsSchema = z.object({
907
- replay: agentCommandSchema,
908
- updateSnapshots: agentCommandSchema
909
- }).strict();
910
- const agentRunRecordSchema = z.object({
911
- $schema: z.string().optional(),
912
- version: z.literal(1),
913
- name: z.string().min(1),
914
- ok: z.boolean(),
915
- startedAt: z.string().min(1),
916
- durationMs: z.number().int().nonnegative(),
917
- mode: agentRunModeSchema,
918
- spec: agentFlowSpecSchema.optional(),
919
- flowPath: z.string().min(1).optional(),
920
- artifactsDir: z.string().min(1),
921
- snapshotDir: z.string().min(1),
922
- reportPath: z.string().min(1),
923
- cassettePath: z.string().min(1).optional(),
924
- cassetteFrameCount: z.number().int().nonnegative(),
925
- replayCommand: z.string().min(1),
926
- commands: agentRunRecordCommandsSchema,
927
- steps: z.array(agentRecordedStepSchema),
928
- artifacts: z.array(agentRunArtifactSchema),
929
- errors: z.array(z.string())
930
- }).strict().superRefine((record, ctx) => {
931
- if (!record.cassettePath && !record.flowPath && !record.spec) ctx.addIssue({
932
- code: z.ZodIssueCode.custom,
933
- message: "agent run record requires cassettePath, flowPath, or spec"
934
- });
935
- const replayCommand = formatArgv(record.commands.replay.argv);
936
- if (record.replayCommand !== replayCommand) ctx.addIssue({
937
- code: z.ZodIssueCode.custom,
938
- path: ["replayCommand"],
939
- message: "replayCommand must match commands.replay.argv"
940
- });
941
- if (!isReplayArgv(record.commands.replay.argv)) ctx.addIssue({
942
- code: z.ZodIssueCode.custom,
943
- path: [
944
- "commands",
945
- "replay",
946
- "argv"
947
- ],
948
- message: "replay argv must be a ptywright agent replay command"
949
- });
950
- const expectedUpdateSnapshots = [...record.commands.replay.argv, "--update-snapshots"];
951
- if (!sameArgv(record.commands.updateSnapshots.argv, expectedUpdateSnapshots)) ctx.addIssue({
952
- code: z.ZodIssueCode.custom,
953
- path: [
954
- "commands",
955
- "updateSnapshots",
956
- "argv"
957
- ],
958
- message: "updateSnapshots argv must extend commands.replay.argv"
959
- });
960
- });
961
- function normalizeAgentRunRecord(input) {
1514
+ //#region src/agent/report_view_options.ts
1515
+ function isMobileViewport(viewport) {
1516
+ return Boolean(viewport?.isMobile || viewport?.hasTouch || (viewport?.width ?? 9999) <= 720);
1517
+ }
1518
+ function resolveReportViewOptions(result) {
1519
+ const launchArgSets = [readFlowLaunchArgs(result.flowPath), readCassetteLaunchArgs(result.replaySourceCassettePath ?? result.cassettePath)];
1520
+ const screenModeArg = readFlagValueFromArgSets(launchArgSets, "--experimental-screen-mode");
1521
+ const themeArg = readFlagValueFromArgSets(launchArgSets, "--theme");
1522
+ const fontSizeArg = readFlagValueFromArgSets(launchArgSets, "--font-size");
1523
+ const lineHeightArg = readFlagValueFromArgSets(launchArgSets, "--line-height");
1524
+ return {
1525
+ fontSize: parsePositiveNumber(fontSizeArg) ?? 15,
1526
+ lineHeight: parsePositiveNumber(lineHeightArg) ?? 1.6,
1527
+ screenMode: screenModeArg && screenModeArg !== "termvision" ? "plain" : "termvision",
1528
+ theme: themeArg === "light" ? "light" : "dark"
1529
+ };
1530
+ }
1531
+ function readCassetteLaunchArgs(cassettePath) {
962
1532
  try {
963
- const parsed = agentRunRecordSchema.parse(input);
964
- return {
965
- ...parsed,
966
- $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json",
967
- spec: parsed.spec ? normalizeAgentFlowSpec(parsed.spec) : void 0
968
- };
969
- } catch (error) {
970
- if (error instanceof z.ZodError) throw new Error(`invalid agent run record: ${formatZodIssues(error)}`);
971
- throw error;
1533
+ return normalizeStringArray(JSON.parse(readFileSync(cassettePath, "utf8")).spec?.launch?.args);
1534
+ } catch {
1535
+ return [];
972
1536
  }
973
1537
  }
974
- function formatAgentArgv(argv) {
975
- return formatArgv(argv);
1538
+ function readFlowLaunchArgs(flowPath) {
1539
+ try {
1540
+ return normalizeStringArray(JSON.parse(readFileSync(flowPath, "utf8")).launch?.args);
1541
+ } catch {
1542
+ return [];
1543
+ }
976
1544
  }
977
- function sameArgv(left, right) {
978
- return left.length === right.length && left.every((value, index) => value === right[index]);
1545
+ function normalizeStringArray(value) {
1546
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
979
1547
  }
980
- function isReplayArgv(argv) {
981
- return argv.length >= 4 && argv[0] === "ptywright" && argv[1] === "agent" && argv[2] === "replay";
1548
+ function readFlagValueFromArgSets(argSets, flag) {
1549
+ for (const args of argSets) {
1550
+ const value = readFlagValue(args, flag);
1551
+ if (value !== void 0) return value;
1552
+ }
982
1553
  }
983
- function readAgentRunRecordPath(path) {
984
- return normalizeAgentRunRecord(JSON.parse(readFileSync(path, "utf8")));
1554
+ function readFlagValue(args, flag) {
1555
+ const index = args.indexOf(flag);
1556
+ return index >= 0 ? args[index + 1] : void 0;
985
1557
  }
986
- function writeAgentRunRecordPath(path, record) {
987
- const normalized = normalizeAgentRunRecord({
988
- ...record,
989
- $schema: record.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json"
1558
+ function parsePositiveNumber(value) {
1559
+ if (value === void 0) return void 0;
1560
+ const parsed = Number(value);
1561
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1562
+ }
1563
+ //#endregion
1564
+ //#region src/agent/report_artifact_viewer.ts
1565
+ function renderArtifactViewerHtml(args) {
1566
+ const { artifact, artifactsDir, content, domPreview, reportPath, terminalDomPreview, viewOptions, viewerPath, viewport } = args;
1567
+ const mobile = isMobileViewport(viewport);
1568
+ return renderArtifactViewerShellHtml({
1569
+ artifact,
1570
+ artifactsDir,
1571
+ contentFragment: renderArtifactViewerFragment({
1572
+ artifactsDir,
1573
+ content,
1574
+ domViewerPreview: artifact.kind === "dom" ? domPreview : artifact.kind === "terminal" ? terminalDomPreview : null,
1575
+ mobile,
1576
+ viewerPath,
1577
+ viewOptions
1578
+ }),
1579
+ mobile,
1580
+ reportPath,
1581
+ viewOptions,
1582
+ viewerPath,
1583
+ viewport
990
1584
  });
991
- writeFileSync(path, JSON.stringify(normalized, null, 2) + "\n", "utf8");
992
1585
  }
993
- function isAgentRunRecordLike(input) {
994
- if (typeof input !== "object" || input === null) return false;
995
- const candidate = input;
996
- return candidate.version === 1 && ("cassettePath" in candidate || "flowPath" in candidate || "spec" in candidate);
1586
+ //#endregion
1587
+ //#region src/agent/report_terminal_layout.ts
1588
+ function resolveTerminalSnapshotLayout(snapshot, viewport, viewOptions) {
1589
+ const cols = inferTerminalCols(snapshot);
1590
+ const rows = inferTerminalRows(snapshot);
1591
+ const mobile = isMobileViewport(viewport);
1592
+ return {
1593
+ cols,
1594
+ fontSize: viewOptions.fontSize,
1595
+ lineHeight: viewOptions.lineHeight,
1596
+ paddingInline: mobile ? 16 : 32,
1597
+ rows
1598
+ };
997
1599
  }
998
- function formatZodIssues(error) {
999
- return error.issues.map((issue) => {
1000
- return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
1001
- }).join("; ");
1600
+ function inferTerminalCols(snapshot) {
1601
+ const dataCols = /data-cols="(\d+)"/.exec(snapshot)?.[1];
1602
+ const styleCols = /--term-cols:\s*(\d+)/.exec(snapshot)?.[1];
1603
+ const parsed = Number.parseInt(dataCols ?? styleCols ?? "", 10);
1604
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 80;
1002
1605
  }
1003
- //#endregion
1004
- //#region src/agent/report.ts
1005
- function writeAgentReport(path, result) {
1006
- mkdirSync(dirname(path), { recursive: true });
1007
- writeFileSync(path, renderAgentReportHtml(result), "utf8");
1606
+ function inferTerminalRows(snapshot) {
1607
+ const dataRows = /data-rows="(\d+)"/.exec(snapshot)?.[1];
1608
+ const styleRows = /--term-rows:\s*(\d+)/.exec(snapshot)?.[1];
1609
+ const parsed = Number.parseInt(dataRows ?? styleRows ?? "", 10);
1610
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 24;
1008
1611
  }
1009
- function renderAgentReportHtml(result) {
1010
- const artifacts = result.artifacts.map((artifact) => renderArtifactRow(artifact, result.artifactsDir, result.reportPath)).join("\n");
1011
- const viewportTabs = result.viewports.map((viewport) => `<span class="pill">${escapeHtml(viewport.name)} ${viewport.width}x${viewport.height}</span>`).join("");
1012
- const status = result.ok ? "passed" : "failed";
1612
+ //#endregion
1613
+ //#region src/agent/report_terminal_preview.ts
1614
+ function renderDomPreviewDocument(snapshot, viewport, viewOptions, aittyAssets) {
1615
+ const body = renderAittyPreviewBody({
1616
+ snapshot,
1617
+ snapshotLayout: resolveTerminalSnapshotLayout(snapshot, viewport, viewOptions),
1618
+ viewOptions
1619
+ });
1620
+ const style = renderAittyPreviewCss(viewOptions);
1621
+ const assetTags = renderAittyPreviewAssetTags(aittyAssets);
1013
1622
  return `<!doctype html>
1014
- <html lang="en">
1623
+ <html lang="en" data-theme="${escapeAttribute(viewOptions.theme)}">
1015
1624
  <head>
1016
1625
  <meta charset="utf-8" />
1017
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1018
- <title>${escapeHtml(`${result.name} terminal-agent report`)}</title>
1019
- <style>
1020
- :root {
1626
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
1627
+ ${assetTags} <style>
1628
+ ${style}
1629
+ </style>
1630
+ </head>
1631
+ <body>
1632
+ ${body}
1633
+ </body>
1634
+ </html>`;
1635
+ }
1636
+ //#endregion
1637
+ //#region src/agent/report_artifact_writer.ts
1638
+ function writeArtifactViewerPages(result) {
1639
+ const viewportsByName = new Map(result.viewports.map((viewport) => [viewport.name, viewport]));
1640
+ const viewOptions = resolveReportViewOptions(result);
1641
+ const readableArtifacts = [];
1642
+ for (const artifact of result.artifacts) {
1643
+ const viewerPath = artifactViewerPath(artifact);
1644
+ if (!viewerPath) continue;
1645
+ const content = readArtifactText(artifact.path);
1646
+ if (content === null) continue;
1647
+ readableArtifacts.push({
1648
+ artifact,
1649
+ content,
1650
+ viewerPath
1651
+ });
1652
+ }
1653
+ const domPreviewPathsBySnapshot = new Map(readableArtifacts.filter(({ artifact }) => artifact.kind === "dom").map(({ artifact }) => [artifactSnapshotKey(artifact), artifactDomPreviewPath(artifact)]));
1654
+ const aittyAssets = domPreviewPathsBySnapshot.size > 0 ? prepareAittyReportAssets({
1655
+ artifactsDir: result.artifactsDir,
1656
+ flowPath: result.flowPath,
1657
+ reportPath: result.reportPath
1658
+ }) : null;
1659
+ const writtenDomPreviewPaths = /* @__PURE__ */ new Set();
1660
+ if (aittyAssets) for (const { artifact, content } of readableArtifacts) {
1661
+ if (artifact.kind !== "dom") continue;
1662
+ const viewport = viewportsByName.get(artifact.viewport);
1663
+ const domPreviewPath = artifactDomPreviewPath(artifact);
1664
+ mkdirSync(dirname(domPreviewPath), { recursive: true });
1665
+ writeFileSync(domPreviewPath, renderDomPreviewDocument(content, viewport, viewOptions, resolveAittyPreviewAssets(domPreviewPath, aittyAssets, result.artifactsDir)), "utf8");
1666
+ writtenDomPreviewPaths.add(domPreviewPath);
1667
+ }
1668
+ for (const { artifact, content, viewerPath } of readableArtifacts) {
1669
+ const viewport = viewportsByName.get(artifact.viewport);
1670
+ const domPreviewPath = artifact.kind === "dom" ? artifactDomPreviewPath(artifact) : null;
1671
+ mkdirSync(dirname(viewerPath), { recursive: true });
1672
+ writeFileSync(viewerPath, renderArtifactViewerHtml({
1673
+ artifact,
1674
+ artifactsDir: result.artifactsDir,
1675
+ content,
1676
+ reportPath: result.reportPath,
1677
+ viewerPath,
1678
+ domPreview: resolveReportDomPreview(domPreviewPath && writtenDomPreviewPaths.has(domPreviewPath) ? domPreviewPath : null),
1679
+ viewOptions,
1680
+ viewport,
1681
+ terminalDomPreview: artifact.kind === "terminal" ? resolveReportDomPreview(resolveWrittenDomPreviewPath(domPreviewPathsBySnapshot, writtenDomPreviewPaths, artifact)) : null
1682
+ }), "utf8");
1683
+ }
1684
+ }
1685
+ function resolveWrittenDomPreviewPath(domPreviewPathsBySnapshot, writtenDomPreviewPaths, artifact) {
1686
+ const path = domPreviewPathsBySnapshot.get(artifactSnapshotKey(artifact));
1687
+ return path && writtenDomPreviewPaths.has(path) ? path : null;
1688
+ }
1689
+ function readArtifactText(path) {
1690
+ try {
1691
+ return readFileSync(path, "utf8");
1692
+ } catch {
1693
+ return null;
1694
+ }
1695
+ }
1696
+ //#endregion
1697
+ //#region src/agent/report_index_artifacts.ts
1698
+ function renderAgentReportArtifacts(args) {
1699
+ return args.artifacts.map((artifact) => renderArtifactRow({
1700
+ artifact,
1701
+ artifactsDir: args.artifactsDir,
1702
+ reportPath: args.reportPath
1703
+ })).join("\n") || "<p>No artifacts were captured.</p>";
1704
+ }
1705
+ function renderArtifactRow(args) {
1706
+ const { artifact, artifactsDir, reportPath } = args;
1707
+ const state = artifact.ok ? "pass" : "fail";
1708
+ const href = relativeHref(reportPath, artifact.path, artifactsDir);
1709
+ const baselineHref = artifact.baselinePath ? relativeHref(reportPath, artifact.baselinePath, artifactsDir) : "";
1710
+ const diffHref = artifact.diffPath ? relativeHref(reportPath, artifact.diffPath, artifactsDir) : "";
1711
+ const viewerPath = artifactViewerPath(artifact);
1712
+ const viewerHref = viewerPath ? relativeHref(reportPath, viewerPath, artifactsDir) : "";
1713
+ return `<article class="artifact">
1714
+ <div class="artifact-summary">
1715
+ <span class="badge ${state}">${state}</span>
1716
+ <div class="artifact-meta">
1717
+ <div class="artifact-links">
1718
+ <a href="${escapeAttribute(viewerHref || href)}">${escapeHtml(artifact.kind)}</a>
1719
+ ${viewerHref ? `<a href="${escapeAttribute(href)}">raw</a>` : ""}
1720
+ ${baselineHref ? `<a href="${escapeAttribute(baselineHref)}">baseline</a>` : ""}
1721
+ ${diffHref ? `<a href="${escapeAttribute(diffHref)}">diff</a>` : ""}
1722
+ </div>
1723
+ <div><code>${escapeHtml(artifact.viewport)} / ${escapeHtml(artifact.name)}</code></div>
1724
+ ${artifact.error ? `<div><code>${escapeHtml(artifact.error)}</code></div>` : ""}
1725
+ </div>
1726
+ <code class="artifact-hash">${escapeHtml(artifact.hash ?? "")}</code>
1727
+ </div>
1728
+ </article>`;
1729
+ }
1730
+ //#endregion
1731
+ //#region src/agent/report_index_commands.ts
1732
+ function renderAgentReportCommandBlock(label, argv) {
1733
+ return `<div class="command">
1734
+ <span>${escapeHtml(label)}</span>
1735
+ <pre>${escapeHtml(formatAgentArgv(argv))}</pre>
1736
+ </div>`;
1737
+ }
1738
+ //#endregion
1739
+ //#region src/agent/report_index_artifacts_css.ts
1740
+ function renderAgentReportArtifactsCss() {
1741
+ return ` .artifacts {
1742
+ display: grid;
1743
+ gap: 14px;
1744
+ }
1745
+ .artifact {
1746
+ display: grid;
1747
+ gap: 12px;
1748
+ border: 1px solid var(--line);
1749
+ border-radius: 8px;
1750
+ padding: 12px;
1751
+ background: var(--panel);
1752
+ }
1753
+ .artifact-summary {
1754
+ display: grid;
1755
+ grid-template-columns: auto minmax(0, 1fr) minmax(96px, auto);
1756
+ gap: 14px;
1757
+ align-items: start;
1758
+ }
1759
+ .artifact-meta {
1760
+ display: grid;
1761
+ gap: 4px;
1762
+ min-width: 0;
1763
+ }
1764
+ .artifact-links {
1765
+ display: flex;
1766
+ flex-wrap: wrap;
1767
+ gap: 8px 12px;
1768
+ }
1769
+ .artifact a {
1770
+ color: var(--focus);
1771
+ font-weight: 700;
1772
+ text-decoration: none;
1773
+ }
1774
+ .artifact code {
1775
+ color: var(--muted);
1776
+ overflow-wrap: anywhere;
1777
+ }
1778
+ .artifact-hash {
1779
+ justify-self: end;
1780
+ text-align: right;
1781
+ }
1782
+ .badge {
1783
+ justify-self: start;
1784
+ border-radius: 999px;
1785
+ padding: 5px 9px;
1786
+ background: color-mix(in oklch, var(--line) 52%, transparent);
1787
+ color: var(--muted);
1788
+ font-size: 12px;
1789
+ font-weight: 700;
1790
+ }
1791
+ .badge.fail {
1792
+ background: color-mix(in oklch, var(--bad) 12%, var(--panel));
1793
+ color: var(--bad);
1794
+ }
1795
+ .badge.pass {
1796
+ background: color-mix(in oklch, var(--good) 12%, var(--panel));
1797
+ color: var(--good);
1798
+ }
1799
+ @media (max-width: 720px) {
1800
+ .artifact-summary {
1801
+ grid-template-columns: 1fr;
1802
+ }
1803
+ .artifact-hash {
1804
+ justify-self: start;
1805
+ text-align: left;
1806
+ }
1807
+ }`;
1808
+ }
1809
+ //#endregion
1810
+ //#region src/agent/report_index_base_css.ts
1811
+ function renderAgentReportBaseCss() {
1812
+ return ` :root {
1021
1813
  color-scheme: light;
1022
1814
  --bg: oklch(97.5% 0.008 210);
1023
1815
  --ink: oklch(19% 0.018 230);
@@ -1085,10 +1877,16 @@ function renderAgentReportHtml(result) {
1085
1877
  font-size: 13px;
1086
1878
  }
1087
1879
  .status {
1088
- border-color: ${result.ok ? "color-mix(in oklch, var(--good) 42%, var(--line))" : "color-mix(in oklch, var(--bad) 44%, var(--line))"};
1089
- color: ${result.ok ? "var(--good)" : "var(--bad)"};
1090
1880
  font-weight: 700;
1091
1881
  }
1882
+ .status.pass {
1883
+ border-color: color-mix(in oklch, var(--good) 42%, var(--line));
1884
+ color: var(--good);
1885
+ }
1886
+ .status.fail {
1887
+ border-color: color-mix(in oklch, var(--bad) 44%, var(--line));
1888
+ color: var(--bad);
1889
+ }
1092
1890
  .panel {
1093
1891
  display: grid;
1094
1892
  gap: 16px;
@@ -1097,73 +1895,23 @@ function renderAgentReportHtml(result) {
1097
1895
  background: var(--panel);
1098
1896
  padding: 18px;
1099
1897
  }
1100
- .summary {
1101
- display: grid;
1102
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1103
- gap: 12px;
1104
- }
1105
- .metric {
1106
- border: 1px solid var(--line);
1107
- border-radius: 8px;
1108
- padding: 14px;
1109
- background: color-mix(in oklch, var(--panel) 82%, var(--bg));
1110
- }
1111
- .metric strong {
1112
- display: block;
1113
- font-size: 24px;
1114
- line-height: 1.1;
1115
- }
1116
- .metric span {
1117
- display: block;
1118
- margin-top: 4px;
1119
- color: var(--muted);
1120
- font-size: 13px;
1121
- }
1122
- .artifacts {
1123
- display: grid;
1124
- gap: 10px;
1125
- }
1126
- .artifact {
1127
- display: grid;
1128
- grid-template-columns: 120px minmax(0, 1fr) auto;
1129
- gap: 14px;
1130
- align-items: center;
1131
- border: 1px solid var(--line);
1132
- border-radius: 8px;
1133
- padding: 12px;
1134
- background: var(--panel);
1135
- }
1136
- .artifact a {
1137
- color: var(--focus);
1138
- font-weight: 700;
1139
- text-decoration: none;
1140
- }
1141
- .artifact code,
1142
- pre {
1143
- font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
1144
- }
1145
- .artifact code {
1146
- color: var(--muted);
1147
- overflow-wrap: anywhere;
1148
- }
1149
- .badge {
1150
- justify-self: start;
1151
- border-radius: 999px;
1152
- padding: 5px 9px;
1153
- background: color-mix(in oklch, var(--line) 52%, transparent);
1154
- color: var(--muted);
1155
- font-size: 12px;
1156
- font-weight: 700;
1157
- }
1158
- .badge.fail {
1159
- background: color-mix(in oklch, var(--bad) 12%, var(--panel));
1160
- color: var(--bad);
1161
- }
1162
- .badge.pass {
1163
- background: color-mix(in oklch, var(--good) 12%, var(--panel));
1164
- color: var(--good);
1165
- }
1166
- .commands {
1898
+ @media (max-width: 720px) {
1899
+ main {
1900
+ width: min(100vw - 20px, 1180px);
1901
+ padding-top: 18px;
1902
+ }
1903
+ header {
1904
+ grid-template-columns: 1fr;
1905
+ }
1906
+ .status {
1907
+ justify-self: start;
1908
+ }
1909
+ }`;
1910
+ }
1911
+ //#endregion
1912
+ //#region src/agent/report_index_commands_css.ts
1913
+ function renderAgentReportCommandsCss() {
1914
+ return ` .commands {
1167
1915
  display: grid;
1168
1916
  gap: 10px;
1169
1917
  }
@@ -1176,6 +1924,10 @@ function renderAgentReportHtml(result) {
1176
1924
  font-size: 13px;
1177
1925
  font-weight: 700;
1178
1926
  }
1927
+ .artifact code,
1928
+ pre {
1929
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
1930
+ }
1179
1931
  pre {
1180
1932
  overflow: auto;
1181
1933
  margin: 0;
@@ -1184,20 +1936,63 @@ function renderAgentReportHtml(result) {
1184
1936
  color: oklch(92% 0.012 230);
1185
1937
  padding: 14px;
1186
1938
  line-height: 1.5;
1939
+ }`;
1940
+ }
1941
+ //#endregion
1942
+ //#region src/agent/report_index_summary_css.ts
1943
+ function renderAgentReportSummaryCss() {
1944
+ return ` .summary {
1945
+ display: grid;
1946
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1947
+ gap: 12px;
1187
1948
  }
1188
- @media (max-width: 720px) {
1189
- main {
1190
- width: min(100vw - 20px, 1180px);
1191
- padding-top: 18px;
1192
- }
1193
- header,
1194
- .artifact {
1195
- grid-template-columns: 1fr;
1196
- }
1197
- .status {
1198
- justify-self: start;
1199
- }
1949
+ .metric {
1950
+ border: 1px solid var(--line);
1951
+ border-radius: 8px;
1952
+ padding: 14px;
1953
+ background: color-mix(in oklch, var(--panel) 82%, var(--bg));
1954
+ }
1955
+ .metric strong {
1956
+ display: block;
1957
+ font-size: 24px;
1958
+ line-height: 1.1;
1200
1959
  }
1960
+ .metric span {
1961
+ display: block;
1962
+ margin-top: 4px;
1963
+ color: var(--muted);
1964
+ font-size: 13px;
1965
+ }`;
1966
+ }
1967
+ //#endregion
1968
+ //#region src/agent/report_index_css.ts
1969
+ function renderAgentReportCss() {
1970
+ return [
1971
+ renderAgentReportBaseCss(),
1972
+ renderAgentReportSummaryCss(),
1973
+ renderAgentReportArtifactsCss(),
1974
+ renderAgentReportCommandsCss()
1975
+ ].join("\n");
1976
+ }
1977
+ //#endregion
1978
+ //#region src/agent/report_index.ts
1979
+ function renderAgentReportHtml(result) {
1980
+ const artifacts = renderAgentReportArtifacts({
1981
+ artifacts: result.artifacts,
1982
+ artifactsDir: result.artifactsDir,
1983
+ reportPath: result.reportPath
1984
+ });
1985
+ const viewportTabs = result.viewports.map((viewport) => `<span class="pill">${escapeHtml(viewport.name)} ${viewport.width}x${viewport.height}</span>`).join("");
1986
+ const status = result.ok ? "passed" : "failed";
1987
+ const statusClass = result.ok ? "pass" : "fail";
1988
+ return `<!doctype html>
1989
+ <html lang="en">
1990
+ <head>
1991
+ <meta charset="utf-8" />
1992
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1993
+ <title>${escapeHtml(`${result.name} terminal-agent report`)}</title>
1994
+ <style>
1995
+ ${renderAgentReportCss()}
1201
1996
  </style>
1202
1997
  </head>
1203
1998
  <body>
@@ -1206,7 +2001,7 @@ function renderAgentReportHtml(result) {
1206
2001
  <div>
1207
2002
  <h1>${escapeHtml(result.name)}</h1>
1208
2003
  <div class="meta">
1209
- <span class="status">${status}</span>
2004
+ <span class="status ${statusClass}">${status}</span>
1210
2005
  <span class="pill">${escapeHtml(result.mode)}</span>
1211
2006
  <span class="pill">${escapeHtml(result.agentFlavor)}</span>
1212
2007
  ${viewportTabs}
@@ -1225,9 +2020,9 @@ function renderAgentReportHtml(result) {
1225
2020
  <section class="panel">
1226
2021
  <h2>Commands</h2>
1227
2022
  <div class="commands">
1228
- ${renderCommandBlock("replay", result.commands.replay.argv)}
1229
- ${renderCommandBlock("update snapshots", result.commands.updateSnapshots.argv)}
1230
- ${renderCommandBlock("inspect commands", [
2023
+ ${renderAgentReportCommandBlock("replay", result.commands.replay.argv)}
2024
+ ${renderAgentReportCommandBlock("update snapshots", result.commands.updateSnapshots.argv)}
2025
+ ${renderAgentReportCommandBlock("inspect commands", [
1231
2026
  "ptywright",
1232
2027
  "agent",
1233
2028
  "commands",
@@ -1241,84 +2036,266 @@ function renderAgentReportHtml(result) {
1241
2036
  <section class="panel">
1242
2037
  <h2>Terminal Agent Artifacts</h2>
1243
2038
  <div class="artifacts">
1244
- ${artifacts || "<p>No artifacts were captured.</p>"}
2039
+ ${artifacts}
1245
2040
  </div>
1246
2041
  </section>
1247
2042
  </main>
1248
2043
  </body>
1249
2044
  </html>`;
1250
2045
  }
1251
- function renderCommandBlock(label, argv) {
1252
- return `<div class="command">
1253
- <span>${escapeHtml(label)}</span>
1254
- <pre>${escapeHtml(formatAgentArgv(argv))}</pre>
1255
- </div>`;
2046
+ //#endregion
2047
+ //#region src/agent/report.ts
2048
+ function writeAgentReport(path, result) {
2049
+ mkdirSync(dirname(path), { recursive: true });
2050
+ writeArtifactViewerPages(result);
2051
+ writeFileSync(path, renderAgentReportHtml(result), "utf8");
2052
+ }
2053
+ //#endregion
2054
+ //#region src/agent/run_artifacts.ts
2055
+ function writeRunRecord(result, spec) {
2056
+ const record = {
2057
+ $schema: AGENT_RUN_RECORD_SCHEMA_URL,
2058
+ version: 1,
2059
+ name: result.name,
2060
+ ok: result.ok,
2061
+ startedAt: new Date(result.startedAt).toISOString(),
2062
+ durationMs: result.durationMs,
2063
+ mode: result.mode,
2064
+ spec,
2065
+ flowPath: relative(dirname(result.recordPath), result.flowPath),
2066
+ artifactsDir: result.artifactsDir,
2067
+ snapshotDir: result.snapshotDir,
2068
+ reportPath: result.reportPath,
2069
+ cassettePath: relative(dirname(result.recordPath), result.cassettePath),
2070
+ cassetteFrameCount: result.cassetteFrameCount,
2071
+ replayCommand: result.replayCommand,
2072
+ commands: result.commands,
2073
+ steps: result.steps,
2074
+ artifacts: result.artifacts,
2075
+ errors: result.errors
2076
+ };
2077
+ writeAgentRunRecordPath(result.recordPath, record);
2078
+ }
2079
+ function writeRunManifest(result) {
2080
+ writeAgentManifestPath(agentManifestPath(result.artifactsDir), {
2081
+ kind: "run",
2082
+ ok: result.ok,
2083
+ rootDir: result.artifactsDir,
2084
+ primaryPath: result.recordPath,
2085
+ commands: result.commands,
2086
+ validation: {
2087
+ ok: result.ok,
2088
+ stages: [{
2089
+ name: "run",
2090
+ ok: result.ok,
2091
+ totalCount: result.artifacts.length,
2092
+ failureCount: result.artifacts.filter((artifact) => !artifact.ok).length
2093
+ }]
2094
+ },
2095
+ files: [
2096
+ {
2097
+ path: result.flowPath,
2098
+ kind: "flow",
2099
+ role: "flow"
2100
+ },
2101
+ {
2102
+ path: result.cassettePath,
2103
+ kind: "cassette",
2104
+ role: "cassette"
2105
+ },
2106
+ {
2107
+ path: result.recordPath,
2108
+ kind: "run-record",
2109
+ role: "record",
2110
+ ok: result.ok
2111
+ },
2112
+ {
2113
+ path: result.reportPath,
2114
+ kind: "report",
2115
+ role: "report",
2116
+ ok: result.ok
2117
+ },
2118
+ ...result.artifacts.flatMap((artifact) => [{
2119
+ path: artifact.path,
2120
+ kind: artifact.kind,
2121
+ role: "artifact",
2122
+ ok: artifact.ok
2123
+ }, {
2124
+ path: artifact.diffPath,
2125
+ kind: "diff",
2126
+ role: "diff",
2127
+ ok: artifact.ok
2128
+ }])
2129
+ ]
2130
+ });
2131
+ }
2132
+ function writeFlowArtifact(path, spec) {
2133
+ writeFileSync(path, JSON.stringify(spec, null, 2) + "\n", "utf8");
2134
+ }
2135
+ //#endregion
2136
+ //#region src/agent/config_defaults.ts
2137
+ function normalizeAgentFlowSpecWithConfig(input, config) {
2138
+ return normalizeAgentFlowSpec(applyAgentConfigDefaults(agentFlowSpecSchema.parse(input), config));
2139
+ }
2140
+ function applyAgentConfigDefaults(input, config) {
2141
+ const agent = config?.agent;
2142
+ if (!agent) return input;
2143
+ const name = sanitizeArtifactName(input.name ?? "agent-flow");
2144
+ const configDefaults = agent.defaults ?? {};
2145
+ const specDefaults = input.defaults ?? {};
2146
+ const viewports = input.viewports ? void 0 : cloneViewports(configDefaults.viewports);
2147
+ return {
2148
+ ...input,
2149
+ artifactsDir: input.artifactsDir ?? resolveNamedDir(agent.artifactsRoot, name, config.rootDir),
2150
+ snapshotDir: input.snapshotDir ?? resolveNamedDir(agent.snapshotDir, name, config.rootDir),
2151
+ viewports: viewports ?? input.viewports,
2152
+ defaults: {
2153
+ ...specDefaults,
2154
+ timeoutMs: specDefaults.timeoutMs ?? configDefaults.timeoutMs,
2155
+ screenshot: specDefaults.screenshot ?? configDefaults.screenshot,
2156
+ mask: mergeMaskRules(configDefaults.mask, specDefaults.mask)
2157
+ }
2158
+ };
2159
+ }
2160
+ function resolveNamedDir(root, name, configRoot) {
2161
+ if (!root) return void 0;
2162
+ const namedDir = join(root, name);
2163
+ return isAbsolute(namedDir) ? namedDir : resolve(configRoot, namedDir);
2164
+ }
2165
+ function cloneViewports(viewports) {
2166
+ return Array.isArray(viewports) && viewports.length > 0 ? viewports.map((viewport) => ({ ...viewport })) : void 0;
2167
+ }
2168
+ function mergeMaskRules(configMask, specMask) {
2169
+ const merged = [...configMask ?? [], ...specMask ?? []];
2170
+ return merged.length > 0 ? merged : void 0;
2171
+ }
2172
+ //#endregion
2173
+ //#region src/agent/command_launch.ts
2174
+ const DEFAULT_URL_REGEX = /https?:\/\/[^\s"'<>]+/;
2175
+ function buildCommandLaunchCommand(launch, options = {}) {
2176
+ if (!launch.command) throw new Error("launch.command is required when launch.mode is 'command'");
2177
+ const rootDir = options.rootDir ?? process.cwd();
2178
+ const cwd = launch.cwd ? resolve(rootDir, launch.cwd) : rootDir;
2179
+ return {
2180
+ file: launch.command,
2181
+ args: launch.args ?? [],
2182
+ cwd,
2183
+ env: {
2184
+ ...options.env ?? process.env,
2185
+ ...launch.env
2186
+ },
2187
+ label: launch.command,
2188
+ urlRegex: launch.urlRegex,
2189
+ waitForUrlMs: launch.waitForUrlMs
2190
+ };
1256
2191
  }
1257
- function renderArtifactRow(artifact, artifactsDir, reportPath) {
1258
- const state = artifact.ok ? "pass" : "fail";
1259
- const href = relativeHref(reportPath, artifact.path, artifactsDir);
1260
- const baselineHref = artifact.baselinePath ? relativeHref(reportPath, artifact.baselinePath, artifactsDir) : "";
1261
- const diffHref = artifact.diffPath ? relativeHref(reportPath, artifact.diffPath, artifactsDir) : "";
1262
- return `<article class="artifact">
1263
- <span class="badge ${state}">${state}</span>
1264
- <div>
1265
- ${artifact.kind === "screenshot" && artifact.path ? `<a href="${escapeAttribute(href)}">open image</a>` : `<a href="${escapeAttribute(href)}">${escapeHtml(artifact.kind)}</a>`}
1266
- <div><code>${escapeHtml(artifact.viewport)} / ${escapeHtml(artifact.name)}</code></div>
1267
- ${baselineHref ? `<div><code>baseline ${escapeHtml(baselineHref)}</code></div>` : ""}
1268
- ${diffHref ? `<div><a href="${escapeAttribute(diffHref)}">diff</a></div>` : ""}
1269
- ${artifact.error ? `<div><code>${escapeHtml(artifact.error)}</code></div>` : ""}
1270
- </div>
1271
- <code>${escapeHtml(artifact.hash ?? "")}</code>
1272
- </article>`;
2192
+ async function launchBrowserSessionFromCommand(command) {
2193
+ const timeoutMs = command.waitForUrlMs ?? 15e3;
2194
+ const child = spawn(command.file, command.args, {
2195
+ cwd: command.cwd,
2196
+ env: command.env,
2197
+ stdio: [
2198
+ "ignore",
2199
+ "pipe",
2200
+ "pipe"
2201
+ ]
2202
+ });
2203
+ const stdoutChunks = [];
2204
+ const stderrChunks = [];
2205
+ return {
2206
+ url: await new Promise((resolveUrl, reject) => {
2207
+ let settled = false;
2208
+ const timer = setTimeout(() => {
2209
+ 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()}`));
2210
+ }, timeoutMs);
2211
+ const finish = (result) => {
2212
+ if (settled) return;
2213
+ settled = true;
2214
+ clearTimeout(timer);
2215
+ child.stdout.off("data", onStdout);
2216
+ child.stderr.off("data", onStderr);
2217
+ child.off("error", onError);
2218
+ child.off("exit", onExit);
2219
+ if (result instanceof Error) reject(result);
2220
+ else resolveUrl(result);
2221
+ };
2222
+ const readUrl = () => {
2223
+ const found = extractUrlFromOutput(`${stdoutChunks.join("")}\n${stderrChunks.join("")}`, command.urlRegex);
2224
+ if (found) finish(found);
2225
+ };
2226
+ const onStdout = (chunk) => {
2227
+ stdoutChunks.push(chunk.toString("utf8"));
2228
+ readUrl();
2229
+ };
2230
+ const onStderr = (chunk) => {
2231
+ stderrChunks.push(chunk.toString("utf8"));
2232
+ readUrl();
2233
+ };
2234
+ const onError = (error) => finish(error);
2235
+ const onExit = (code, signal) => {
2236
+ 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()}`));
2237
+ };
2238
+ child.stdout.on("data", onStdout);
2239
+ child.stderr.on("data", onStderr);
2240
+ child.once("error", onError);
2241
+ child.once("exit", onExit);
2242
+ }),
2243
+ process: child,
2244
+ close: () => closeChild(child)
2245
+ };
1273
2246
  }
1274
- function relativeHref(_reportPath, targetPath, artifactsDir) {
1275
- if (targetPath.startsWith(artifactsDir)) return targetPath.slice(artifactsDir.length).replace(/^\/+/, "");
1276
- return targetPath;
2247
+ function extractUrlFromOutput(output, regexSource) {
2248
+ if (!regexSource) return output.match(DEFAULT_URL_REGEX)?.[0] ?? null;
2249
+ const match = output.match(new RegExp(regexSource, "m"));
2250
+ return match?.[1] ?? match?.[0] ?? null;
1277
2251
  }
1278
- function escapeHtml(input) {
1279
- return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2252
+ function formatBrowserLaunchCommand(command) {
2253
+ return [command.file, ...command.args].join(" ");
1280
2254
  }
1281
- function escapeAttribute(input) {
1282
- return escapeHtml(input).replace(/'/g, "&#39;");
2255
+ async function closeChild(child) {
2256
+ if (child.exitCode !== null || child.signalCode !== null) return;
2257
+ await new Promise((resolveClose) => {
2258
+ const timer = setTimeout(() => {
2259
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
2260
+ resolveClose();
2261
+ }, 2e3);
2262
+ child.once("exit", () => {
2263
+ clearTimeout(timer);
2264
+ resolveClose();
2265
+ });
2266
+ child.kill("SIGTERM");
2267
+ });
1283
2268
  }
1284
2269
  //#endregion
1285
- //#region src/agent/spec_loader.ts
1286
- async function loadAgentSpec(specPath) {
1287
- const resolved = resolve(process.cwd(), specPath);
1288
- if (resolved.endsWith(".json")) return {
1289
- spec: normalizeAgentFlowSpec(JSON.parse(readFileSync(resolved, "utf8"))),
1290
- path: resolved
2270
+ //#region src/agent/launch.ts
2271
+ function buildAgentLaunchCommand(launch, options = {}) {
2272
+ if (resolveAgentLaunchMode(launch) === "url") return null;
2273
+ return buildCommandLaunchCommand(launch, options);
2274
+ }
2275
+ async function resolveAgentLaunchTarget(launch, options = {}) {
2276
+ const mode = resolveAgentLaunchMode(launch);
2277
+ if (mode === "url") return {
2278
+ mode,
2279
+ url: launch.url,
2280
+ session: null
1291
2281
  };
1292
- const mod = await import(`${pathToFileURL(resolved).href}?t=${Date.now()}`);
2282
+ const session = await launchBrowserSessionFromCommand(buildCommandLaunchCommand(launch, options));
1293
2283
  return {
1294
- spec: normalizeAgentFlowSpec(mod.default ?? mod.spec),
1295
- path: resolved
2284
+ mode,
2285
+ url: session.url,
2286
+ session
1296
2287
  };
1297
2288
  }
1298
- //#endregion
1299
- //#region src/agent/runner.ts
1300
- async function runAgentSpecPath(specPath, options = {}) {
1301
- return runAgentSpec((await loadAgentSpec(specPath)).spec, options);
1302
- }
1303
- async function replayAgentRecordPath(recordPath, options = {}) {
1304
- const raw = JSON.parse(readFileSync(recordPath, "utf8"));
1305
- if (isAgentCassetteLike(raw)) return replayAgentCassette(normalizeAgentCassette(raw), recordPath, options);
1306
- const record = readAgentRunRecordPath(recordPath);
1307
- if (record.cassettePath) {
1308
- const cassettePath = isAbsolute(record.cassettePath) ? record.cassettePath : resolve(dirname(recordPath), record.cassettePath);
1309
- return replayAgentCassette(readAgentCassettePath(cassettePath, record.spec), cassettePath, {
1310
- ...options,
1311
- artifactsDir: options.artifactsDir ?? join(dirname(recordPath), "replay")
1312
- });
1313
- }
1314
- if (record.spec) return runAgentSpec(record.spec, options);
1315
- if (!record.flowPath) throw new Error(`invalid agent run record: missing replay source in ${recordPath}`);
1316
- return runAgentSpecPath(isAbsolute(record.flowPath) ? record.flowPath : resolve(dirname(recordPath), record.flowPath), options);
2289
+ function formatAgentLaunchCommand(launch) {
2290
+ const command = buildAgentLaunchCommand(launch);
2291
+ return command ? formatBrowserLaunchCommand(command) : "launch.mode=url";
1317
2292
  }
1318
- async function runAgentSpec(input, options = {}) {
2293
+ //#endregion
2294
+ //#region src/agent/runner_setup.ts
2295
+ function prepareAgentRun(input, options) {
1319
2296
  const startedAt = Date.now();
1320
2297
  const rootDir = options.rootDir ? resolve(process.cwd(), options.rootDir) : process.cwd();
1321
- const spec = normalizeAgentFlowSpec(input);
2298
+ const spec = normalizeAgentFlowSpecWithConfig(input, options.replayCassette ? void 0 : options.config);
1322
2299
  const name = sanitizeArtifactName(spec.name ?? "agent-flow");
1323
2300
  const artifactsDir = resolve(rootDir, options.artifactsDir ?? spec.artifactsDir ?? join(".tmp", "agent", name));
1324
2301
  const snapshotDir = resolve(rootDir, spec.snapshotDir ?? join("snapshots", name));
@@ -1336,161 +2313,51 @@ async function runAgentSpec(input, options = {}) {
1336
2313
  "replay",
1337
2314
  relative(process.cwd(), recordPath)
1338
2315
  ];
1339
- const result = {
1340
- ok: true,
1341
- name,
1342
- mode: options.replayCassette ? "replay" : "live",
2316
+ return {
1343
2317
  startedAt,
1344
- durationMs: 0,
1345
- artifactsDir,
1346
- snapshotDir,
1347
- reportPath,
1348
- recordPath,
1349
- flowPath,
1350
- cassettePath,
1351
- replaySourceCassettePath: options.replaySourceCassettePath,
1352
- replayCommand: formatAgentArgv(replayArgv),
1353
- commands: {
1354
- replay: { argv: replayArgv },
1355
- updateSnapshots: { argv: [...replayArgv, "--update-snapshots"] }
1356
- },
1357
- agentFlavor: resolveAgentFlavor(spec),
1358
- viewports: spec.viewports ?? [],
1359
- cassetteFrameCount: cassette.frames.length,
1360
- steps: [],
1361
- artifacts: [],
1362
- errors: []
1363
- };
1364
- writeFileSync(flowPath, JSON.stringify(spec, null, 2) + "\n", "utf8");
1365
- let browser = null;
1366
- try {
1367
- browser = await launchAgentBrowser({ headless: options.headless ?? true });
1368
- for (const viewport of spec.viewports ?? []) await runViewport({
1369
- browser,
1370
- spec,
1371
- viewport,
1372
- rootDir,
1373
- artifactsDir,
1374
- snapshotDir,
1375
- updateSnapshots,
1376
- recordCassette: !options.replayCassette,
1377
- cassette,
1378
- result
1379
- });
1380
- } catch (error) {
1381
- result.ok = false;
1382
- result.errors.push(error instanceof Error ? error.message : String(error));
1383
- } finally {
1384
- await closeBrowserSafely(browser);
1385
- result.durationMs = Date.now() - startedAt;
1386
- if (options.replaySourceCassettePath) writeFileSync(cassettePath, readFileSync(options.replaySourceCassettePath, "utf8"), "utf8");
1387
- else writeFileSync(cassettePath, JSON.stringify(cassette, null, 2) + "\n", "utf8");
1388
- result.cassetteFrameCount = cassette.frames.length;
1389
- writeRunRecord(result, spec);
1390
- writeAgentReport(reportPath, result);
1391
- writeRunManifest(result);
1392
- }
1393
- return result;
1394
- }
1395
- async function runViewport(args) {
1396
- const { browser, spec, viewport, rootDir, artifactsDir, snapshotDir, updateSnapshots, recordCassette, cassette, result } = args;
1397
- const launchTarget = await resolveAgentLaunchTarget(spec.launch, { rootDir });
1398
- const context = await browser.newContext({
1399
- viewport: {
1400
- width: viewport.width,
1401
- height: viewport.height
1402
- },
1403
- deviceScaleFactor: viewport.deviceScaleFactor,
1404
- isMobile: viewport.isMobile,
1405
- hasTouch: viewport.hasTouch
1406
- });
1407
- const page = await context.newPage();
1408
- const ctx = {
2318
+ rootDir,
1409
2319
  spec,
1410
- viewport,
1411
- page,
1412
- artifactsDir,
1413
- snapshotDir,
1414
- updateSnapshots,
1415
- recordCassette,
1416
- masks: resolveAgentMasks(spec),
1417
- artifacts: result.artifacts,
1418
- replay: Boolean(args.cassette && !args.recordCassette),
1419
- cassette,
1420
- nextReplayPhase: 0
1421
- };
1422
- try {
1423
- await page.goto(launchTarget.url, {
1424
- waitUntil: "domcontentloaded",
1425
- timeout: spec.defaults?.timeoutMs ?? 3e4
1426
- });
1427
- await waitForTerminalRoot(page, spec.defaults?.timeoutMs ?? 3e4);
1428
- await captureCassetteFrame(ctx, {
1429
- stepIndex: null,
1430
- stepType: "initial"
1431
- });
1432
- for (let i = 0; i < spec.steps.length; i += 1) {
1433
- const step = spec.steps[i];
1434
- const started = Date.now();
1435
- try {
1436
- await runStep(ctx, step);
1437
- result.steps.push({
1438
- index: i,
1439
- type: step.type,
1440
- label: formatStepLabel(step),
1441
- durationMs: Date.now() - started,
1442
- ok: true
1443
- });
1444
- await captureCassetteFrame(ctx, {
1445
- stepIndex: i,
1446
- stepType: step.type
1447
- });
1448
- } catch (error) {
1449
- const message = error instanceof Error ? error.message : String(error);
1450
- result.ok = false;
1451
- result.errors.push(`${viewport.name} step ${i + 1} ${step.type}: ${message}`);
1452
- result.steps.push({
1453
- index: i,
1454
- type: step.type,
1455
- label: formatStepLabel(step),
1456
- durationMs: Date.now() - started,
1457
- ok: false,
1458
- error: message
1459
- });
1460
- break;
1461
- }
1462
- }
1463
- } finally {
1464
- await withTimeout(context.close().catch(() => void 0), 5e3);
1465
- await launchTarget.session?.close();
1466
- }
1467
- }
1468
- async function replayAgentCassette(cassette, cassettePath, options) {
1469
- const server = await startAgentCassetteServer(cassette);
1470
- try {
1471
- const replaySpec = structuredClone(cassette.spec);
1472
- const replayCassette = structuredClone(cassette);
1473
- return await runAgentSpec({
1474
- ...replaySpec,
1475
- launch: {
1476
- mode: "url",
1477
- url: server.url,
1478
- agentFlavor: replaySpec.launch.agentFlavor
1479
- }
1480
- }, {
1481
- ...options,
1482
- artifactsDir: options.artifactsDir ?? join(dirname(cassettePath), "replay"),
1483
- replayCassette,
1484
- replaySourceCassettePath: cassettePath
1485
- });
1486
- } finally {
1487
- await server.close();
1488
- }
2320
+ name,
2321
+ artifactsDir,
2322
+ snapshotDir,
2323
+ reportPath,
2324
+ recordPath,
2325
+ flowPath,
2326
+ cassettePath,
2327
+ updateSnapshots,
2328
+ cassette,
2329
+ result: {
2330
+ ok: true,
2331
+ name,
2332
+ mode: options.replayCassette ? "replay" : "live",
2333
+ startedAt,
2334
+ durationMs: 0,
2335
+ artifactsDir,
2336
+ snapshotDir,
2337
+ reportPath,
2338
+ recordPath,
2339
+ flowPath,
2340
+ cassettePath,
2341
+ replaySourceCassettePath: options.replaySourceCassettePath,
2342
+ replayCommand: formatAgentArgv(replayArgv),
2343
+ commands: {
2344
+ replay: { argv: replayArgv },
2345
+ updateSnapshots: { argv: [...replayArgv, "--update-snapshots"] }
2346
+ },
2347
+ agentFlavor: resolveAgentFlavor(spec),
2348
+ viewports: spec.viewports ?? [],
2349
+ cassetteFrameCount: cassette.frames.length,
2350
+ steps: [],
2351
+ artifacts: [],
2352
+ errors: []
2353
+ }
2354
+ };
1489
2355
  }
1490
- async function closeBrowserSafely(browser) {
1491
- if (!browser) return;
1492
- await withTimeout(browser.close().catch(() => void 0), 5e3);
2356
+ function formatAgentLaunchPlan(input) {
2357
+ return formatAgentLaunchCommand(normalizeAgentFlowSpec(input).launch);
1493
2358
  }
2359
+ //#endregion
2360
+ //#region src/agent/timeout.ts
1494
2361
  async function withTimeout(promise, timeoutMs) {
1495
2362
  let timer;
1496
2363
  try {
@@ -1501,85 +2368,95 @@ async function withTimeout(promise, timeoutMs) {
1501
2368
  if (timer) clearTimeout(timer);
1502
2369
  }
1503
2370
  }
1504
- async function runStep(ctx, step) {
1505
- const timeout = ctx.spec.defaults?.timeoutMs ?? 3e4;
1506
- if (step.type === "waitForText") {
1507
- await waitForTerminalText(ctx.page, {
1508
- text: step.text,
1509
- regex: step.regex,
1510
- timeoutMs: step.timeoutMs ?? timeout
1511
- });
1512
- return;
1513
- }
1514
- if (step.type === "typeText") {
1515
- await advanceReplayPhase(ctx);
1516
- if (!ctx.replay) {
1517
- await ctx.page.locator("[data-terminal-root]").first().click({ timeout });
1518
- await ctx.page.keyboard.type(step.text, { delay: step.delayMs });
1519
- if (step.enter) await ctx.page.keyboard.press("Enter");
2371
+ //#endregion
2372
+ //#region src/agent/step_label.ts
2373
+ function formatAgentStepLabel(step) {
2374
+ if (step.type === "snapshot") return `snapshot ${step.name}`;
2375
+ if (step.type === "waitForText") return `wait ${step.text ?? step.regex ?? ""}`;
2376
+ if (step.type === "typeText") return `type ${step.text.slice(0, 24)}`;
2377
+ if (step.type === "pressKey") return `press ${step.key}`;
2378
+ if (step.type === "click") return `click ${step.selector ?? step.text ?? `${step.x},${step.y}`}`;
2379
+ if (step.type === "mark") return `mark ${step.label ?? ""}`;
2380
+ if (step.type === "sleep") return `sleep ${step.ms}ms`;
2381
+ return step.type;
2382
+ }
2383
+ //#endregion
2384
+ //#region src/agent/snapshot_diff.ts
2385
+ function renderSnapshotDiff(expected, received) {
2386
+ const expectedLines = expected.split("\n");
2387
+ const receivedLines = received.split("\n");
2388
+ const max = Math.max(expectedLines.length, receivedLines.length);
2389
+ const out = ["--- expected", "+++ received"];
2390
+ for (let i = 0; i < max; i += 1) {
2391
+ const before = expectedLines[i];
2392
+ const after = receivedLines[i];
2393
+ if (before === after) {
2394
+ if (before !== void 0) out.push(` ${before}`);
2395
+ continue;
1520
2396
  }
1521
- return;
2397
+ if (before !== void 0) out.push(`- ${before}`);
2398
+ if (after !== void 0) out.push(`+ ${after}`);
1522
2399
  }
1523
- if (step.type === "pressKey") {
1524
- await advanceReplayPhase(ctx);
1525
- if (!ctx.replay) await ctx.page.keyboard.press(step.key);
1526
- return;
2400
+ return out.join("\n") + "\n";
2401
+ }
2402
+ //#endregion
2403
+ //#region src/agent/terminal_dom.ts
2404
+ async function waitForTerminalRoot(page, timeoutMs) {
2405
+ await page.locator("[data-terminal-root]").first().waitFor({
2406
+ state: "attached",
2407
+ timeout: timeoutMs
2408
+ });
2409
+ }
2410
+ async function waitForTerminalText(page, args) {
2411
+ const started = Date.now();
2412
+ const matcher = args.regex ? new RegExp(args.regex) : null;
2413
+ while (Date.now() - started < args.timeoutMs) {
2414
+ const text = await readTerminalText(page);
2415
+ if (args.text && text.includes(args.text)) return;
2416
+ if (matcher?.test(text)) return;
2417
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
1527
2418
  }
1528
- if (step.type === "click") {
1529
- await advanceReplayPhase(ctx);
1530
- if (ctx.replay) return;
1531
- if (step.selector) {
1532
- await ctx.page.locator(step.selector).first().click({ timeout });
1533
- return;
1534
- }
1535
- if (step.text) {
1536
- await ctx.page.getByText(step.text, { exact: false }).first().click({ timeout });
1537
- return;
2419
+ throw new Error(`timed out waiting for terminal text ${args.text ?? args.regex ?? ""}`);
2420
+ }
2421
+ async function waitForStableDom(page, args) {
2422
+ const started = Date.now();
2423
+ let last = "";
2424
+ let stableSince = Date.now();
2425
+ while (Date.now() - started < args.timeoutMs) {
2426
+ const current = await readTerminalDomIfPresent(page);
2427
+ if (current === null) {
2428
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
2429
+ continue;
1538
2430
  }
1539
- await ctx.page.mouse.click(step.x, step.y);
1540
- return;
1541
- }
1542
- if (step.type === "waitForStableDom") {
1543
- await waitForStableDom(ctx.page, {
1544
- timeoutMs: step.timeoutMs ?? timeout,
1545
- quietMs: step.quietMs ?? 350,
1546
- intervalMs: step.intervalMs ?? 100
1547
- });
1548
- return;
1549
- }
1550
- if (step.type === "snapshot") {
1551
- await captureSnapshotStep(ctx, step);
1552
- return;
1553
- }
1554
- if (step.type === "sleep") {
1555
- if (!ctx.replay) await new Promise((resolveSleep) => setTimeout(resolveSleep, step.ms));
1556
- return;
2431
+ if (current !== last) {
2432
+ last = current;
2433
+ stableSince = Date.now();
2434
+ } else if (Date.now() - stableSince >= args.quietMs) return;
2435
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
1557
2436
  }
1558
- if (step.type === "mark") return;
2437
+ throw new Error(`timed out waiting for stable terminal DOM`);
1559
2438
  }
1560
- async function captureCassetteFrame(ctx, frame) {
1561
- if (!ctx.cassette || !ctx.recordCassette) return;
1562
- const phase = ctx.nextReplayPhase;
1563
- upsertAgentCassetteFrame(ctx.cassette, {
1564
- viewport: ctx.viewport,
1565
- phase,
1566
- stepIndex: frame.stepIndex,
1567
- stepType: frame.stepType,
1568
- terminalText: normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks),
1569
- dom: normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks),
1570
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
2439
+ async function readTerminalText(page) {
2440
+ const text = await page.evaluate(() => {
2441
+ const node = document.querySelector("[data-terminal-root]");
2442
+ if (!node) return null;
2443
+ const rows = Array.from(node.querySelectorAll(".term-grid .term-row"));
2444
+ if (rows.length > 0) return rows.map((row) => row.textContent ?? "").join("\n");
2445
+ return node.textContent ?? "";
1571
2446
  });
2447
+ if (text === null) throw new Error("terminal root is not attached");
2448
+ return text;
1572
2449
  }
1573
- async function advanceReplayPhase(ctx) {
1574
- if (!ctx.replay) {
1575
- ctx.nextReplayPhase += 1;
1576
- return;
1577
- }
1578
- ctx.nextReplayPhase += 1;
1579
- await ctx.page.evaluate((phase) => {
1580
- window.__ptywrightReplaySetPhase?.(phase);
1581
- }, ctx.nextReplayPhase);
2450
+ async function readTerminalDom(page) {
2451
+ const dom = await readTerminalDomIfPresent(page);
2452
+ if (dom === null) throw new Error("terminal root is not attached");
2453
+ return dom;
2454
+ }
2455
+ async function readTerminalDomIfPresent(page) {
2456
+ return page.evaluate(() => document.querySelector("[data-terminal-root]")?.innerHTML ?? null);
1582
2457
  }
2458
+ //#endregion
2459
+ //#region src/agent/snapshot_artifacts.ts
1583
2460
  async function captureSnapshotStep(ctx, step) {
1584
2461
  const targets = step.targets ?? [
1585
2462
  "terminal",
@@ -1587,7 +2464,8 @@ async function captureSnapshotStep(ctx, step) {
1587
2464
  ...ctx.spec.defaults?.screenshot ? ["screenshot"] : []
1588
2465
  ];
1589
2466
  const base = `${sanitizeArtifactName(ctx.viewport.name)}.${sanitizeArtifactName(step.name)}`;
1590
- for (const target of targets) {
2467
+ const errors = [];
2468
+ for (const target of targets) try {
1591
2469
  if (target === "terminal") {
1592
2470
  const text = normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks);
1593
2471
  await writeComparableArtifact(ctx, {
@@ -1626,7 +2504,10 @@ async function captureSnapshotStep(ctx, step) {
1626
2504
  path: screenshotPath,
1627
2505
  ok: true
1628
2506
  });
2507
+ } catch (error) {
2508
+ errors.push(error instanceof Error ? error.message : String(error));
1629
2509
  }
2510
+ if (errors.length > 0) throw new Error(errors.join("; "));
1630
2511
  }
1631
2512
  async function writeComparableArtifact(ctx, artifact) {
1632
2513
  const artifactPath = join(ctx.artifactsDir, artifact.relativePath);
@@ -1703,172 +2584,255 @@ async function writeComparableArtifact(ctx, artifact) {
1703
2584
  ok: true
1704
2585
  });
1705
2586
  }
1706
- function renderSnapshotDiff(expected, received) {
1707
- const expectedLines = expected.split("\n");
1708
- const receivedLines = received.split("\n");
1709
- const max = Math.max(expectedLines.length, receivedLines.length);
1710
- const out = ["--- expected", "+++ received"];
1711
- for (let i = 0; i < max; i += 1) {
1712
- const before = expectedLines[i];
1713
- const after = receivedLines[i];
1714
- if (before === after) {
1715
- if (before !== void 0) out.push(` ${before}`);
1716
- continue;
2587
+ //#endregion
2588
+ //#region src/agent/step_runner.ts
2589
+ async function runAgentStep(ctx, step) {
2590
+ const timeout = ctx.spec.defaults?.timeoutMs ?? 3e4;
2591
+ if (step.type === "waitForText") {
2592
+ await waitForTerminalText(ctx.page, {
2593
+ text: step.text,
2594
+ regex: step.regex,
2595
+ timeoutMs: step.timeoutMs ?? timeout
2596
+ });
2597
+ return;
2598
+ }
2599
+ if (step.type === "typeText") {
2600
+ await advanceReplayPhase(ctx);
2601
+ if (!ctx.replay) {
2602
+ await ctx.page.locator("[data-terminal-root]").first().click({ timeout });
2603
+ await ctx.page.keyboard.type(step.text, { delay: step.delayMs });
2604
+ if (step.enter) await ctx.page.keyboard.press("Enter");
1717
2605
  }
1718
- if (before !== void 0) out.push(`- ${before}`);
1719
- if (after !== void 0) out.push(`+ ${after}`);
2606
+ return;
1720
2607
  }
1721
- return out.join("\n") + "\n";
1722
- }
1723
- async function waitForTerminalRoot(page, timeoutMs) {
1724
- await page.locator("[data-terminal-root]").first().waitFor({
1725
- state: "attached",
1726
- timeout: timeoutMs
1727
- });
1728
- }
1729
- async function waitForTerminalText(page, args) {
1730
- const started = Date.now();
1731
- const matcher = args.regex ? new RegExp(args.regex) : null;
1732
- while (Date.now() - started < args.timeoutMs) {
1733
- const text = await readTerminalText(page);
1734
- if (args.text && text.includes(args.text)) return;
1735
- if (matcher?.test(text)) return;
1736
- await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
2608
+ if (step.type === "pressKey") {
2609
+ await advanceReplayPhase(ctx);
2610
+ if (!ctx.replay) await ctx.page.keyboard.press(step.key);
2611
+ return;
1737
2612
  }
1738
- throw new Error(`timed out waiting for terminal text ${args.text ?? args.regex ?? ""}`);
1739
- }
1740
- async function waitForStableDom(page, args) {
1741
- const started = Date.now();
1742
- let last = "";
1743
- let stableSince = Date.now();
1744
- while (Date.now() - started < args.timeoutMs) {
1745
- const current = await readTerminalDomIfPresent(page);
1746
- if (current === null) {
1747
- await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
1748
- continue;
2613
+ if (step.type === "click") {
2614
+ await advanceReplayPhase(ctx);
2615
+ if (ctx.replay) return;
2616
+ if (step.selector) {
2617
+ await ctx.page.locator(step.selector).first().click({ timeout });
2618
+ return;
1749
2619
  }
1750
- if (current !== last) {
1751
- last = current;
1752
- stableSince = Date.now();
1753
- } else if (Date.now() - stableSince >= args.quietMs) return;
1754
- await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
2620
+ if (step.text) {
2621
+ await ctx.page.getByText(step.text, { exact: false }).first().click({ timeout });
2622
+ return;
2623
+ }
2624
+ await ctx.page.mouse.click(step.x, step.y);
2625
+ return;
2626
+ }
2627
+ if (step.type === "waitForStableDom") {
2628
+ await waitForStableDom(ctx.page, {
2629
+ timeoutMs: step.timeoutMs ?? timeout,
2630
+ quietMs: step.quietMs ?? 350,
2631
+ intervalMs: step.intervalMs ?? 100
2632
+ });
2633
+ return;
2634
+ }
2635
+ if (step.type === "snapshot") {
2636
+ await captureSnapshotStep(ctx, step);
2637
+ return;
2638
+ }
2639
+ if (step.type === "sleep") {
2640
+ if (!ctx.replay) await new Promise((resolveSleep) => setTimeout(resolveSleep, step.ms));
2641
+ return;
1755
2642
  }
1756
- throw new Error(`timed out waiting for stable terminal DOM`);
2643
+ if (step.type === "mark") return;
1757
2644
  }
1758
- async function readTerminalText(page) {
1759
- const text = await page.evaluate(() => {
1760
- const node = document.querySelector("[data-terminal-root]");
1761
- if (!node) return null;
1762
- const rows = Array.from(node.querySelectorAll(".term-grid .term-row"));
1763
- if (rows.length > 0) return rows.map((row) => row.textContent ?? "").join("\n");
1764
- return node.textContent ?? "";
2645
+ async function captureCassetteFrame(ctx, frame) {
2646
+ if (!ctx.cassette || !ctx.recordCassette) return;
2647
+ const phase = ctx.nextReplayPhase;
2648
+ upsertAgentCassetteFrame(ctx.cassette, {
2649
+ viewport: ctx.viewport,
2650
+ phase,
2651
+ stepIndex: frame.stepIndex,
2652
+ stepType: frame.stepType,
2653
+ terminalText: normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks),
2654
+ dom: normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks),
2655
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
1765
2656
  });
1766
- if (text === null) throw new Error("terminal root is not attached");
1767
- return text;
1768
2657
  }
1769
- async function readTerminalDom(page) {
1770
- const dom = await readTerminalDomIfPresent(page);
1771
- if (dom === null) throw new Error("terminal root is not attached");
1772
- return dom;
1773
- }
1774
- async function readTerminalDomIfPresent(page) {
1775
- return page.evaluate(() => document.querySelector("[data-terminal-root]")?.innerHTML ?? null);
2658
+ async function advanceReplayPhase(ctx) {
2659
+ if (!ctx.replay) {
2660
+ ctx.nextReplayPhase += 1;
2661
+ return;
2662
+ }
2663
+ ctx.nextReplayPhase += 1;
2664
+ await ctx.page.evaluate((phase) => {
2665
+ window.__ptywrightReplaySetPhase?.(phase);
2666
+ }, ctx.nextReplayPhase);
1776
2667
  }
1777
- function writeRunRecord(result, spec) {
1778
- const record = {
1779
- $schema: AGENT_RUN_RECORD_SCHEMA_URL,
1780
- version: 1,
1781
- name: result.name,
1782
- ok: result.ok,
1783
- startedAt: new Date(result.startedAt).toISOString(),
1784
- durationMs: result.durationMs,
1785
- mode: result.mode,
2668
+ //#endregion
2669
+ //#region src/agent/viewport_runner.ts
2670
+ async function runAgentViewport(args) {
2671
+ const { browser, spec, viewport, rootDir, artifactsDir, snapshotDir, updateSnapshots, recordCassette, cassette, result } = args;
2672
+ const launchTarget = await resolveAgentLaunchTarget(spec.launch, { rootDir });
2673
+ const context = await browser.newContext({
2674
+ viewport: {
2675
+ width: viewport.width,
2676
+ height: viewport.height
2677
+ },
2678
+ deviceScaleFactor: viewport.deviceScaleFactor,
2679
+ isMobile: viewport.isMobile,
2680
+ hasTouch: viewport.hasTouch
2681
+ });
2682
+ const page = await context.newPage();
2683
+ const ctx = {
1786
2684
  spec,
1787
- flowPath: relative(dirname(result.recordPath), result.flowPath),
1788
- artifactsDir: result.artifactsDir,
1789
- snapshotDir: result.snapshotDir,
1790
- reportPath: result.reportPath,
1791
- cassettePath: relative(dirname(result.recordPath), result.cassettePath),
1792
- cassetteFrameCount: result.cassetteFrameCount,
1793
- replayCommand: result.replayCommand,
1794
- commands: result.commands,
1795
- steps: result.steps,
2685
+ viewport,
2686
+ page,
2687
+ artifactsDir,
2688
+ snapshotDir,
2689
+ updateSnapshots,
2690
+ recordCassette,
2691
+ masks: resolveAgentMasks(spec),
1796
2692
  artifacts: result.artifacts,
1797
- errors: result.errors
2693
+ replay: Boolean(args.cassette && !args.recordCassette),
2694
+ cassette,
2695
+ nextReplayPhase: 0
1798
2696
  };
1799
- writeAgentRunRecordPath(result.recordPath, record);
2697
+ try {
2698
+ await page.goto(resolveViewportUrl(launchTarget.url, viewport), {
2699
+ waitUntil: "domcontentloaded",
2700
+ timeout: spec.defaults?.timeoutMs ?? 3e4
2701
+ });
2702
+ await waitForTerminalRoot(page, spec.defaults?.timeoutMs ?? 3e4);
2703
+ await captureCassetteFrame(ctx, {
2704
+ stepIndex: null,
2705
+ stepType: "initial"
2706
+ });
2707
+ for (let i = 0; i < spec.steps.length; i += 1) {
2708
+ const step = spec.steps[i];
2709
+ const started = Date.now();
2710
+ try {
2711
+ await runAgentStep(ctx, step);
2712
+ result.steps.push({
2713
+ index: i,
2714
+ type: step.type,
2715
+ label: formatAgentStepLabel(step),
2716
+ durationMs: Date.now() - started,
2717
+ ok: true
2718
+ });
2719
+ await captureCassetteFrame(ctx, {
2720
+ stepIndex: i,
2721
+ stepType: step.type
2722
+ });
2723
+ } catch (error) {
2724
+ const message = error instanceof Error ? error.message : String(error);
2725
+ result.ok = false;
2726
+ result.errors.push(`${viewport.name} step ${i + 1} ${step.type}: ${message}`);
2727
+ result.steps.push({
2728
+ index: i,
2729
+ type: step.type,
2730
+ label: formatAgentStepLabel(step),
2731
+ durationMs: Date.now() - started,
2732
+ ok: false,
2733
+ error: message
2734
+ });
2735
+ break;
2736
+ }
2737
+ }
2738
+ } finally {
2739
+ await withTimeout(context.close().catch(() => void 0), 5e3);
2740
+ await launchTarget.session?.close();
2741
+ }
1800
2742
  }
1801
- function writeRunManifest(result) {
1802
- writeAgentManifestPath(agentManifestPath(result.artifactsDir), {
1803
- kind: "run",
1804
- ok: result.ok,
1805
- rootDir: result.artifactsDir,
1806
- primaryPath: result.recordPath,
1807
- commands: result.commands,
1808
- validation: {
1809
- ok: result.ok,
1810
- stages: [{
1811
- name: "run",
1812
- ok: result.ok,
1813
- totalCount: result.artifacts.length,
1814
- failureCount: result.artifacts.filter((artifact) => !artifact.ok).length
1815
- }]
1816
- },
1817
- files: [
1818
- {
1819
- path: result.flowPath,
1820
- kind: "flow",
1821
- role: "flow"
1822
- },
1823
- {
1824
- path: result.cassettePath,
1825
- kind: "cassette",
1826
- role: "cassette"
1827
- },
1828
- {
1829
- path: result.recordPath,
1830
- kind: "run-record",
1831
- role: "record",
1832
- ok: result.ok
1833
- },
1834
- {
1835
- path: result.reportPath,
1836
- kind: "report",
1837
- role: "report",
1838
- ok: result.ok
1839
- },
1840
- ...result.artifacts.flatMap((artifact) => [{
1841
- path: artifact.path,
1842
- kind: artifact.kind,
1843
- role: "artifact",
1844
- ok: artifact.ok
1845
- }, {
1846
- path: artifact.diffPath,
1847
- kind: "diff",
1848
- role: "diff",
1849
- ok: artifact.ok
1850
- }])
1851
- ]
2743
+ function resolveViewportUrl(url, viewport) {
2744
+ return url.replaceAll("{viewportName}", encodeURIComponent(viewport.name)).replaceAll("{viewportWidth}", encodeURIComponent(String(viewport.width))).replaceAll("{viewportHeight}", encodeURIComponent(String(viewport.height)));
2745
+ }
2746
+ //#endregion
2747
+ //#region src/agent/runner.ts
2748
+ async function runAgentSpecPath(specPath, options = {}) {
2749
+ return runAgentSpec((await loadAgentSpec(specPath)).raw, options);
2750
+ }
2751
+ async function replayAgentRecordPath(recordPath, options = {}) {
2752
+ const raw = JSON.parse(readFileSync(recordPath, "utf8"));
2753
+ if (isAgentCassetteLike(raw)) return replayAgentCassette(normalizeAgentCassette(raw), recordPath, options);
2754
+ const record = readAgentRunRecordPath(recordPath);
2755
+ if (record.cassettePath) {
2756
+ const cassettePath = isAbsolute(record.cassettePath) ? record.cassettePath : resolve(dirname(recordPath), record.cassettePath);
2757
+ return replayAgentCassette(readAgentCassettePath(cassettePath, record.spec), cassettePath, {
2758
+ ...options,
2759
+ artifactsDir: options.artifactsDir ?? join(dirname(recordPath), "replay")
2760
+ });
2761
+ }
2762
+ if (record.spec) return runAgentSpec(record.spec, {
2763
+ ...options,
2764
+ config: void 0
2765
+ });
2766
+ if (!record.flowPath) throw new Error(`invalid agent run record: missing replay source in ${recordPath}`);
2767
+ return runAgentSpecPath(isAbsolute(record.flowPath) ? record.flowPath : resolve(dirname(recordPath), record.flowPath), {
2768
+ ...options,
2769
+ config: void 0
1852
2770
  });
1853
2771
  }
1854
- function formatStepLabel(step) {
1855
- if (step.type === "snapshot") return `snapshot ${step.name}`;
1856
- if (step.type === "waitForText") return `wait ${step.text ?? step.regex ?? ""}`;
1857
- if (step.type === "typeText") return `type ${step.text.slice(0, 24)}`;
1858
- if (step.type === "pressKey") return `press ${step.key}`;
1859
- if (step.type === "click") return `click ${step.selector ?? step.text ?? `${step.x},${step.y}`}`;
1860
- if (step.type === "mark") return `mark ${step.label ?? ""}`;
1861
- if (step.type === "sleep") return `sleep ${step.ms}ms`;
1862
- return step.type;
2772
+ async function runAgentSpec(input, options = {}) {
2773
+ const { artifactsDir, cassette, cassettePath, flowPath, reportPath, result, rootDir, snapshotDir, spec, startedAt, updateSnapshots } = prepareAgentRun(input, options);
2774
+ writeFlowArtifact(flowPath, spec);
2775
+ let browser = null;
2776
+ try {
2777
+ browser = await launchAgentBrowser({ headless: options.headless ?? true });
2778
+ for (const viewport of spec.viewports ?? []) await runAgentViewport({
2779
+ browser,
2780
+ spec,
2781
+ viewport,
2782
+ rootDir,
2783
+ artifactsDir,
2784
+ snapshotDir,
2785
+ updateSnapshots,
2786
+ recordCassette: !options.replayCassette,
2787
+ cassette,
2788
+ result
2789
+ });
2790
+ } catch (error) {
2791
+ result.ok = false;
2792
+ result.errors.push(error instanceof Error ? error.message : String(error));
2793
+ } finally {
2794
+ await closeBrowserSafely(browser);
2795
+ result.durationMs = Date.now() - startedAt;
2796
+ if (options.replaySourceCassettePath) writeFileSync(cassettePath, readFileSync(options.replaySourceCassettePath, "utf8"), "utf8");
2797
+ else writeFileSync(cassettePath, JSON.stringify(cassette, null, 2) + "\n", "utf8");
2798
+ result.cassetteFrameCount = cassette.frames.length;
2799
+ writeRunRecord(result, spec);
2800
+ writeAgentReport(reportPath, result);
2801
+ writeRunManifest(result);
2802
+ }
2803
+ return result;
2804
+ }
2805
+ async function replayAgentCassette(cassette, cassettePath, options) {
2806
+ const server = await startAgentCassetteServer(cassette);
2807
+ try {
2808
+ const replaySpec = structuredClone(cassette.spec);
2809
+ const replayCassette = structuredClone(cassette);
2810
+ return await runAgentSpec({
2811
+ ...replaySpec,
2812
+ launch: {
2813
+ mode: "url",
2814
+ url: withReplayViewportQuery(server.url),
2815
+ agentFlavor: replaySpec.launch.agentFlavor
2816
+ }
2817
+ }, {
2818
+ ...options,
2819
+ artifactsDir: options.artifactsDir ?? join(dirname(cassettePath), "replay"),
2820
+ replayCassette,
2821
+ replaySourceCassettePath: cassettePath
2822
+ });
2823
+ } finally {
2824
+ await server.close();
2825
+ }
1863
2826
  }
1864
- function envTruthy(value) {
1865
- return value === "1" || value === "true" || value === "yes";
2827
+ function withReplayViewportQuery(url) {
2828
+ return `${url}${url.includes("?") ? "&" : "?"}viewportName={viewportName}&viewportWidth={viewportWidth}&viewportHeight={viewportHeight}`;
1866
2829
  }
1867
- function printAgentLaunchPlan(input) {
1868
- return formatAgentLaunchCommand(normalizeAgentFlowSpec(input).launch);
2830
+ async function closeBrowserSafely(browser) {
2831
+ if (!browser) return;
2832
+ await withTimeout(browser.close().catch(() => void 0), 5e3);
1869
2833
  }
1870
2834
  function defaultSpecNameForPath(path) {
1871
2835
  return sanitizeArtifactName(basename(path, extname(path)));
1872
2836
  }
1873
2837
  //#endregion
1874
- export { isAgentManifestLike as C, writeAgentManifestPath as E, agentManifestPath as S, validateAgentManifestFiles as T, readAgentCassettePath as _, runAgentSpecPath as a, launchAgentBrowser as b, agentRunModeSchema as c, readAgentRunRecordPath as d, writeAgentRunRecordPath as f, isAgentCassetteLike as g, resolveAgentLaunchTarget as h, runAgentSpec as i, formatAgentArgv as l, createAgentTemplateSpec as m, printAgentLaunchPlan as n, loadAgentSpec as o, formatArgv as p, replayAgentRecordPath as r, AGENT_RUN_RECORD_SCHEMA_URL as s, defaultSpecNameForPath as t, isAgentRunRecordLike as u, normalizeAgentFlowSpec as v, readAgentManifestPath as w, AGENT_MANIFEST_FILE_NAME as x, sanitizeArtifactName as y };
2838
+ 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 };