ptywright 0.4.0 → 0.6.0

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