ptywright 0.4.0 → 0.5.0

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