ptywright 0.1.0 → 0.2.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.
Files changed (68) hide show
  1. package/README.md +459 -116
  2. package/dist/agent.mjs +2 -0
  3. package/dist/bin/ptywright.mjs +6 -0
  4. package/dist/cli-DIUx2w6X.mjs +3587 -0
  5. package/dist/cli.mjs +2 -0
  6. package/{src/index.ts → dist/index.mjs} +7 -9
  7. package/dist/mcp.mjs +2 -0
  8. package/dist/pty-cassette.mjs +24 -0
  9. package/dist/pty_like-Cpkh_O9B.mjs +404 -0
  10. package/dist/runner-DzZlFrt1.mjs +1897 -0
  11. package/dist/runner-zApMYWZx.mjs +3257 -0
  12. package/dist/script.mjs +2 -0
  13. package/dist/server-VHuEWWj_.mjs +3068 -0
  14. package/dist/session.mjs +2 -0
  15. package/dist/terminal_session-DopC7Xg6.mjs +893 -0
  16. package/package.json +28 -21
  17. package/schemas/ptywright-agent-cassette.schema.json +57 -0
  18. package/schemas/ptywright-agent-check.schema.json +122 -0
  19. package/schemas/ptywright-agent-manifest.schema.json +107 -0
  20. package/schemas/ptywright-agent-promote.schema.json +146 -0
  21. package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
  22. package/schemas/ptywright-agent-run.schema.json +126 -0
  23. package/schemas/ptywright-agent.schema.json +182 -0
  24. package/schemas/ptywright-pty-cassette.schema.json +86 -0
  25. package/schemas/ptywright-script-manifest.schema.json +75 -0
  26. package/schemas/ptywright-script-run-summary.schema.json +114 -0
  27. package/schemas/ptywright-script.schema.json +55 -3
  28. package/skills/ptywright-testing/SKILL.md +53 -33
  29. package/bin/ptywright +0 -4
  30. package/src/cli.ts +0 -414
  31. package/src/generator/doc_parser.ts +0 -341
  32. package/src/generator/generate.ts +0 -161
  33. package/src/generator/index.ts +0 -10
  34. package/src/generator/script_generator.ts +0 -209
  35. package/src/generator/step_extractor.ts +0 -397
  36. package/src/mcp/http_server.ts +0 -174
  37. package/src/mcp/script_recording.ts +0 -238
  38. package/src/mcp/server.ts +0 -1348
  39. package/src/pty/bun_pty_adapter.ts +0 -34
  40. package/src/pty/bun_terminal_adapter.ts +0 -149
  41. package/src/pty/pty_adapter.ts +0 -31
  42. package/src/script/dsl.ts +0 -188
  43. package/src/script/module.ts +0 -43
  44. package/src/script/path.ts +0 -151
  45. package/src/script/run.ts +0 -108
  46. package/src/script/run_all.ts +0 -229
  47. package/src/script/runner.ts +0 -983
  48. package/src/script/schema.ts +0 -237
  49. package/src/script/steps/assert_snapshot_equals.ts +0 -21
  50. package/src/script/steps/index.ts +0 -2
  51. package/src/script/suite_report.ts +0 -626
  52. package/src/session/session_manager.ts +0 -145
  53. package/src/session/terminal_session.ts +0 -473
  54. package/src/terminal/ansi.ts +0 -142
  55. package/src/terminal/keys.ts +0 -180
  56. package/src/terminal/mask.ts +0 -70
  57. package/src/terminal/mouse.ts +0 -75
  58. package/src/terminal/snapshot.ts +0 -196
  59. package/src/terminal/style.ts +0 -121
  60. package/src/terminal/view.ts +0 -49
  61. package/src/trace/asciicast.ts +0 -20
  62. package/src/trace/asciinema_player_assets.ts +0 -44
  63. package/src/trace/cast_to_txt.ts +0 -116
  64. package/src/trace/recorder.ts +0 -110
  65. package/src/trace/report.ts +0 -2092
  66. package/src/types.ts +0 -86
  67. package/src/util/hash.ts +0 -8
  68. package/src/util/sleep.ts +0 -5
@@ -0,0 +1,1897 @@
1
+ import { z } from "zod";
2
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { createHash } from "node:crypto";
6
+ import { spawn } from "node:child_process";
7
+ import { chromium } from "playwright";
8
+ import { createServer } from "node:http";
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/aitty.ts
175
+ function buildAittyExecCommand(launch, options = {}) {
176
+ if (!launch.command) throw new Error("launch.command is required for aitty mode");
177
+ const rootDir = options.rootDir ?? process.cwd();
178
+ const cwd = launch.cwd ? resolve(rootDir, launch.cwd) : rootDir;
179
+ const aitty = launch.aitty ?? {};
180
+ const cli = resolveAittyCliCommand(aitty.command, rootDir, options.env ?? process.env);
181
+ const args = [
182
+ ...cli.args,
183
+ "exec",
184
+ "--launch",
185
+ "print"
186
+ ];
187
+ pushOption(args, "--cwd", cwd);
188
+ pushOption(args, "--host", aitty.host);
189
+ pushOption(args, "--port", aitty.port);
190
+ pushOption(args, "--project", aitty.project);
191
+ pushOption(args, "--label", aitty.label);
192
+ pushOption(args, "--title", aitty.title);
193
+ pushOption(args, "--subtitle", aitty.subtitle);
194
+ pushOption(args, "--theme", aitty.theme && aitty.theme !== "auto" ? aitty.theme : void 0);
195
+ pushOption(args, "--font-size", aitty.fontSize);
196
+ pushOption(args, "--experimental-screen-mode", aitty.screenMode);
197
+ args.push("--", launch.command, ...launch.args ?? []);
198
+ return {
199
+ file: cli.file,
200
+ args,
201
+ cwd,
202
+ env: {
203
+ ...options.env ?? process.env,
204
+ ...launch.env
205
+ }
206
+ };
207
+ }
208
+ async function launchAittyBrowserSession(launch, options = {}) {
209
+ const command = buildAittyExecCommand(launch, options);
210
+ const timeoutMs = launch.aitty?.waitForUrlMs ?? 15e3;
211
+ const child = spawn(command.file, command.args, {
212
+ cwd: command.cwd,
213
+ env: command.env,
214
+ stdio: [
215
+ "ignore",
216
+ "pipe",
217
+ "pipe"
218
+ ]
219
+ });
220
+ const chunks = [];
221
+ const stderrChunks = [];
222
+ return {
223
+ url: await new Promise((resolveUrl, reject) => {
224
+ let settled = false;
225
+ const timer = setTimeout(() => {
226
+ finish(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for aitty session URL\nstderr=${stderrChunks.join("").trim()}`));
227
+ }, timeoutMs);
228
+ const finish = (result) => {
229
+ if (settled) return;
230
+ settled = true;
231
+ clearTimeout(timer);
232
+ child.stdout.off("data", onStdout);
233
+ child.stderr.off("data", onStderr);
234
+ child.off("error", onError);
235
+ child.off("exit", onExit);
236
+ if (result instanceof Error) reject(result);
237
+ else resolveUrl(result);
238
+ };
239
+ const onStdout = (chunk) => {
240
+ const text = chunk.toString("utf8");
241
+ chunks.push(text);
242
+ const found = extractAittyUrlFromOutput(chunks.join(""));
243
+ if (found) finish(found);
244
+ };
245
+ const onStderr = (chunk) => {
246
+ stderrChunks.push(chunk.toString("utf8"));
247
+ };
248
+ const onError = (error) => finish(error);
249
+ const onExit = (code, signal) => {
250
+ finish(/* @__PURE__ */ new Error(`aitty exited before printing a session URL (code=${code ?? "null"} signal=${signal ?? "null"})\nstdout=${chunks.join("").trim()}\nstderr=${stderrChunks.join("").trim()}`));
251
+ };
252
+ child.stdout.on("data", onStdout);
253
+ child.stderr.on("data", onStderr);
254
+ child.once("error", onError);
255
+ child.once("exit", onExit);
256
+ }),
257
+ process: child,
258
+ close: () => closeChild(child)
259
+ };
260
+ }
261
+ function extractAittyUrlFromOutput(output) {
262
+ return output.match(/https?:\/\/[^\s"'<>]+/)?.[0] ?? null;
263
+ }
264
+ function resolveAittyCliCommand(explicitCommand, rootDir, env) {
265
+ if (explicitCommand) return {
266
+ file: explicitCommand,
267
+ args: []
268
+ };
269
+ if (env.PTYWRIGHT_AITTY_CLI) return {
270
+ file: env.PTYWRIGHT_AITTY_CLI,
271
+ args: []
272
+ };
273
+ const siblingDist = resolve(rootDir, "../aitty/packages/cli/dist/cli.js");
274
+ if (existsSync(siblingDist)) return {
275
+ file: "node",
276
+ args: [siblingDist]
277
+ };
278
+ return {
279
+ file: "aitty",
280
+ args: []
281
+ };
282
+ }
283
+ function pushOption(args, name, value) {
284
+ if (value === void 0 || value === "") return;
285
+ args.push(name, String(value));
286
+ }
287
+ async function closeChild(child) {
288
+ if (child.exitCode !== null || child.signalCode !== null) return;
289
+ await new Promise((resolveClose) => {
290
+ const timer = setTimeout(() => {
291
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
292
+ resolveClose();
293
+ }, 2e3);
294
+ child.once("exit", () => {
295
+ clearTimeout(timer);
296
+ resolveClose();
297
+ });
298
+ child.kill("SIGTERM");
299
+ });
300
+ }
301
+ //#endregion
302
+ //#region src/agent/browser.ts
303
+ async function launchAgentBrowser(args) {
304
+ let lastError;
305
+ for (let attempt = 0; attempt < 5; attempt += 1) try {
306
+ const browser = await chromium.launch({ headless: args.headless });
307
+ await verifyBrowserLaunch(browser);
308
+ return browser;
309
+ } catch (error) {
310
+ lastError = error;
311
+ if (!isTransientBrowserLaunchError(error) || attempt === 4) throw error;
312
+ await new Promise((resolveRetry) => setTimeout(resolveRetry, 250 * (attempt + 1)));
313
+ }
314
+ throw lastError;
315
+ }
316
+ async function verifyBrowserLaunch(browser) {
317
+ const context = await browser.newContext();
318
+ try {
319
+ const page = await context.newPage();
320
+ await page.goto("data:text/html,<title>ptywright</title>", { waitUntil: "load" });
321
+ await page.close();
322
+ } catch (error) {
323
+ await browser.close();
324
+ throw error;
325
+ } finally {
326
+ await context.close().catch(() => void 0);
327
+ }
328
+ }
329
+ function isTransientBrowserLaunchError(error) {
330
+ const fields = errorFields(error);
331
+ 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";
332
+ }
333
+ function errorFields(error) {
334
+ const record = typeof error === "object" && error !== null ? error : {};
335
+ return {
336
+ message: error instanceof Error ? error.message : String(error),
337
+ code: typeof record.code === "string" ? record.code : "",
338
+ syscall: typeof record.syscall === "string" ? record.syscall : ""
339
+ };
340
+ }
341
+ //#endregion
342
+ //#region src/agent/normalize.ts
343
+ function applyAgentMasks(input, rules = []) {
344
+ let out = input;
345
+ for (const rule of rules) {
346
+ const flags = rule.flags ?? "g";
347
+ const regex = new RegExp(rule.regex, flags.includes("g") ? flags : `${flags}g`);
348
+ const replacement = rule.replacement ?? "<masked>";
349
+ out = out.replace(regex, (match) => {
350
+ if (!rule.preserveLength) return replacement;
351
+ if (replacement.length === match.length) return replacement;
352
+ if (replacement.length > match.length) return replacement.slice(0, match.length);
353
+ return replacement + "*".repeat(match.length - replacement.length);
354
+ });
355
+ }
356
+ return out;
357
+ }
358
+ function normalizeTerminalText(input, rules = []) {
359
+ const lines = applyAgentMasks(input.replace(/\r\n?/g, "\n"), rules).split("\n").map((line) => line.trimEnd());
360
+ while (lines[0]?.trim() === "") lines.shift();
361
+ while (lines.at(-1)?.trim() === "") lines.pop();
362
+ return lines.join("\n");
363
+ }
364
+ function normalizeDomSnapshot(input, rules = []) {
365
+ 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);
366
+ }
367
+ function sanitizeArtifactName(input) {
368
+ return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "artifact";
369
+ }
370
+ function shortHash(input) {
371
+ let hash = 2166136261;
372
+ for (let i = 0; i < input.length; i += 1) {
373
+ hash ^= input.charCodeAt(i);
374
+ hash = Math.imul(hash, 16777619) >>> 0;
375
+ }
376
+ return hash.toString(16).padStart(8, "0");
377
+ }
378
+ //#endregion
379
+ //#region src/agent/schema.ts
380
+ const agentTextMaskRuleSchema = z.object({
381
+ regex: z.string().min(1),
382
+ flags: z.string().optional(),
383
+ replacement: z.string().optional(),
384
+ preserveLength: z.boolean().optional()
385
+ });
386
+ const agentViewportSchema = z.object({
387
+ name: z.string().min(1),
388
+ width: z.number().int().positive(),
389
+ height: z.number().int().positive(),
390
+ deviceScaleFactor: z.number().positive().optional(),
391
+ isMobile: z.boolean().optional(),
392
+ hasTouch: z.boolean().optional()
393
+ });
394
+ const agentLaunchSchema = z.object({
395
+ mode: z.enum(["aitty", "url"]).optional(),
396
+ agentFlavor: z.enum([
397
+ "codex",
398
+ "claude",
399
+ "droid",
400
+ "generic"
401
+ ]).optional(),
402
+ command: z.string().min(1).optional(),
403
+ args: z.array(z.string()).optional(),
404
+ cwd: z.string().optional(),
405
+ env: z.record(z.string()).optional(),
406
+ url: z.string().url().optional(),
407
+ aitty: z.object({
408
+ command: z.string().min(1).optional(),
409
+ args: z.array(z.string()).optional(),
410
+ project: z.string().min(1).optional(),
411
+ label: z.string().min(1).optional(),
412
+ title: z.string().min(1).optional(),
413
+ subtitle: z.string().min(1).optional(),
414
+ theme: z.enum([
415
+ "dark",
416
+ "light",
417
+ "auto"
418
+ ]).optional(),
419
+ fontSize: z.number().int().min(11).max(24).optional(),
420
+ screenMode: z.enum(["termvision"]).optional(),
421
+ port: z.number().int().min(0).max(65535).optional(),
422
+ host: z.string().min(1).optional(),
423
+ waitForUrlMs: z.number().int().positive().optional()
424
+ }).optional()
425
+ }).superRefine((value, ctx) => {
426
+ const mode = value.mode ?? (value.url ? "url" : "aitty");
427
+ if (mode === "url" && !value.url) ctx.addIssue({
428
+ code: z.ZodIssueCode.custom,
429
+ message: "launch.url is required when launch.mode is 'url'"
430
+ });
431
+ if (mode === "aitty" && !value.command) ctx.addIssue({
432
+ code: z.ZodIssueCode.custom,
433
+ message: "launch.command is required when launch.mode is 'aitty'"
434
+ });
435
+ });
436
+ const waitForTextStepSchema = z.object({
437
+ type: z.literal("waitForText"),
438
+ text: z.string().optional(),
439
+ regex: z.string().optional(),
440
+ timeoutMs: z.number().int().positive().optional()
441
+ }).superRefine((value, ctx) => {
442
+ if (!value.text && !value.regex) ctx.addIssue({
443
+ code: z.ZodIssueCode.custom,
444
+ message: "waitForText requires text or regex"
445
+ });
446
+ });
447
+ const typeTextStepSchema = z.object({
448
+ type: z.literal("typeText"),
449
+ text: z.string(),
450
+ enter: z.boolean().optional(),
451
+ delayMs: z.number().int().nonnegative().optional()
452
+ });
453
+ const pressKeyStepSchema = z.object({
454
+ type: z.literal("pressKey"),
455
+ key: z.string().min(1)
456
+ });
457
+ const clickStepSchema = z.object({
458
+ type: z.literal("click"),
459
+ selector: z.string().min(1).optional(),
460
+ text: z.string().min(1).optional(),
461
+ x: z.number().int().nonnegative().optional(),
462
+ y: z.number().int().nonnegative().optional()
463
+ }).superRefine((value, ctx) => {
464
+ if (!value.selector && !value.text && (value.x === void 0 || value.y === void 0)) ctx.addIssue({
465
+ code: z.ZodIssueCode.custom,
466
+ message: "click requires selector, text, or x/y coordinates"
467
+ });
468
+ });
469
+ const waitForStableDomStepSchema = z.object({
470
+ type: z.literal("waitForStableDom"),
471
+ timeoutMs: z.number().int().positive().optional(),
472
+ quietMs: z.number().int().positive().optional(),
473
+ intervalMs: z.number().int().positive().optional()
474
+ });
475
+ const snapshotStepSchema = z.object({
476
+ type: z.literal("snapshot"),
477
+ name: z.string().min(1),
478
+ compare: z.boolean().optional(),
479
+ targets: z.array(z.enum([
480
+ "terminal",
481
+ "dom",
482
+ "screenshot"
483
+ ])).optional(),
484
+ fullPage: z.boolean().optional()
485
+ });
486
+ const markStepSchema = z.object({
487
+ type: z.literal("mark"),
488
+ label: z.string().optional()
489
+ });
490
+ const sleepStepSchema = z.object({
491
+ type: z.literal("sleep"),
492
+ ms: z.number().int().nonnegative()
493
+ });
494
+ const agentFlowStepSchema = z.union([
495
+ waitForTextStepSchema,
496
+ typeTextStepSchema,
497
+ pressKeyStepSchema,
498
+ clickStepSchema,
499
+ waitForStableDomStepSchema,
500
+ snapshotStepSchema,
501
+ markStepSchema,
502
+ sleepStepSchema
503
+ ]);
504
+ const agentFlowSpecSchema = z.object({
505
+ name: z.string().min(1).optional(),
506
+ artifactsDir: z.string().optional(),
507
+ snapshotDir: z.string().optional(),
508
+ launch: agentLaunchSchema,
509
+ viewports: z.array(agentViewportSchema).min(1).optional(),
510
+ defaults: z.object({
511
+ timeoutMs: z.number().int().positive().optional(),
512
+ mask: z.array(agentTextMaskRuleSchema).optional(),
513
+ screenshot: z.boolean().optional()
514
+ }).optional(),
515
+ steps: z.array(agentFlowStepSchema).min(1)
516
+ });
517
+ const DEFAULT_AGENT_VIEWPORTS = [{
518
+ name: "desktop-1440",
519
+ width: 1440,
520
+ height: 960,
521
+ deviceScaleFactor: 1
522
+ }];
523
+ function normalizeAgentFlowSpec(input) {
524
+ const spec = agentFlowSpecSchema.parse(input);
525
+ return {
526
+ ...spec,
527
+ name: spec.name ?? "agent-flow",
528
+ viewports: spec.viewports?.length ? spec.viewports : [...DEFAULT_AGENT_VIEWPORTS]
529
+ };
530
+ }
531
+ //#endregion
532
+ //#region src/agent/cassette.ts
533
+ const AGENT_CASSETTE_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json";
534
+ const agentCassetteFrameSchema = z.object({
535
+ viewport: agentViewportSchema,
536
+ phase: z.number().int().nonnegative(),
537
+ stepIndex: z.number().int().nonnegative().nullable(),
538
+ stepType: z.string().min(1),
539
+ terminalText: z.string(),
540
+ terminalHash: z.string().min(1),
541
+ dom: z.string(),
542
+ domHash: z.string().min(1),
543
+ capturedAt: z.string().min(1)
544
+ });
545
+ const agentCassetteSchema = z.object({
546
+ $schema: z.string().optional(),
547
+ version: z.literal(1),
548
+ name: z.string().min(1),
549
+ createdAt: z.string().min(1),
550
+ spec: agentFlowSpecSchema.optional(),
551
+ frames: z.array(agentCassetteFrameSchema).min(1)
552
+ });
553
+ function createAgentCassette(name, spec) {
554
+ return {
555
+ $schema: AGENT_CASSETTE_SCHEMA_URL,
556
+ version: 1,
557
+ name,
558
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
559
+ spec: normalizeAgentFlowSpec(spec),
560
+ frames: []
561
+ };
562
+ }
563
+ function normalizeAgentCassette(input, fallbackSpec) {
564
+ const parsed = agentCassetteSchema.parse(input);
565
+ const specInput = parsed.spec ?? fallbackSpec;
566
+ if (!specInput) throw new Error("invalid agent cassette: missing spec");
567
+ validateCassetteFrameHashes(parsed.frames);
568
+ return {
569
+ ...parsed,
570
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-cassette.schema.json",
571
+ spec: normalizeAgentFlowSpec(specInput)
572
+ };
573
+ }
574
+ function validateCassetteFrameHashes(frames) {
575
+ for (const frame of frames) {
576
+ if (shortHash(frame.terminalText) !== frame.terminalHash) throw new Error(`invalid agent cassette: terminal hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
577
+ if (shortHash(frame.dom) !== frame.domHash) throw new Error(`invalid agent cassette: dom hash mismatch viewport=${frame.viewport.name} phase=${frame.phase}`);
578
+ }
579
+ }
580
+ function readAgentCassettePath(path, fallbackSpec) {
581
+ return normalizeAgentCassette(JSON.parse(readFileSync(path, "utf8")), fallbackSpec);
582
+ }
583
+ function isAgentCassetteLike(input) {
584
+ return typeof input === "object" && input !== null && input.version === 1 && Array.isArray(input.frames);
585
+ }
586
+ function upsertAgentCassetteFrame(cassette, frame) {
587
+ const next = {
588
+ ...frame,
589
+ terminalHash: shortHash(frame.terminalText),
590
+ domHash: shortHash(frame.dom)
591
+ };
592
+ const index = cassette.frames.findIndex((candidate) => candidate.viewport.name === next.viewport.name && candidate.phase === next.phase);
593
+ if (index >= 0) {
594
+ cassette.frames[index] = next;
595
+ return;
596
+ }
597
+ cassette.frames.push(next);
598
+ }
599
+ async function startAgentCassetteServer(cassette) {
600
+ const sockets = /* @__PURE__ */ new Set();
601
+ const server = createServer((request, response) => {
602
+ if (request.url === "/health") {
603
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
604
+ response.end("ok");
605
+ return;
606
+ }
607
+ response.writeHead(200, {
608
+ "cache-control": "no-store",
609
+ "content-type": "text/html; charset=utf-8"
610
+ });
611
+ response.end(renderCassetteHtml(cassette));
612
+ });
613
+ server.on("connection", (socket) => {
614
+ sockets.add(socket);
615
+ socket.once("close", () => {
616
+ sockets.delete(socket);
617
+ });
618
+ });
619
+ await new Promise((resolveListen, rejectListen) => {
620
+ const onError = (error) => {
621
+ server.off("listening", onListening);
622
+ rejectListen(error);
623
+ };
624
+ const onListening = () => {
625
+ server.off("error", onError);
626
+ resolveListen();
627
+ };
628
+ server.once("error", onError);
629
+ server.once("listening", onListening);
630
+ server.listen(0, "127.0.0.1");
631
+ });
632
+ const address = server.address();
633
+ if (!address || typeof address === "string") {
634
+ await closeServer(server);
635
+ throw new Error("failed to bind cassette replay server");
636
+ }
637
+ return {
638
+ url: `http://127.0.0.1:${address.port}/`,
639
+ close: () => closeServer(server, sockets)
640
+ };
641
+ }
642
+ function renderCassetteHtml(cassette) {
643
+ const framesJson = JSON.stringify(cassette.frames).replace(/</g, "\\u003c");
644
+ return `<!doctype html>
645
+ <html lang="en">
646
+ <head>
647
+ <meta charset="utf-8" />
648
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
649
+ <title>${escapeHtml$1(cassette.name)} cassette replay</title>
650
+ <style>
651
+ :root {
652
+ color-scheme: dark;
653
+ background: #101418;
654
+ color: #eef3f8;
655
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
656
+ }
657
+ body {
658
+ margin: 0;
659
+ background: #101418;
660
+ }
661
+ [data-terminal-root] {
662
+ min-height: 100vh;
663
+ outline: none;
664
+ }
665
+ .term-grid {
666
+ white-space: pre;
667
+ }
668
+ .term-row {
669
+ min-height: 1em;
670
+ }
671
+ </style>
672
+ </head>
673
+ <body>
674
+ <div data-terminal-root tabindex="0"></div>
675
+ <script>
676
+ const frames = ${framesJson};
677
+ const root = document.querySelector("[data-terminal-root]");
678
+ let phase = 0;
679
+
680
+ function viewportScore(frame) {
681
+ const viewport = frame.viewport || {};
682
+ return Math.abs((viewport.width || window.innerWidth) - window.innerWidth) +
683
+ Math.abs((viewport.height || window.innerHeight) - window.innerHeight);
684
+ }
685
+
686
+ function chooseFrame() {
687
+ const eligible = frames
688
+ .filter((frame) => Number(frame.phase || 0) <= phase)
689
+ .sort((a, b) => {
690
+ const phaseDelta = Number(b.phase || 0) - Number(a.phase || 0);
691
+ if (phaseDelta !== 0) return phaseDelta;
692
+ return viewportScore(a) - viewportScore(b);
693
+ });
694
+ return eligible[0] || frames.slice().sort((a, b) => viewportScore(a) - viewportScore(b))[0];
695
+ }
696
+
697
+ function renderText(text) {
698
+ const grid = document.createElement("div");
699
+ grid.className = "term-grid";
700
+ for (const line of String(text || "").split("\\n")) {
701
+ const row = document.createElement("div");
702
+ row.className = "term-row";
703
+ const span = document.createElement("span");
704
+ span.textContent = line;
705
+ row.appendChild(span);
706
+ grid.appendChild(row);
707
+ }
708
+ root.replaceChildren(grid);
709
+ }
710
+
711
+ function render() {
712
+ const frame = chooseFrame();
713
+ if (!frame) {
714
+ renderText("");
715
+ return;
716
+ }
717
+ if (frame.dom) {
718
+ root.innerHTML = frame.dom;
719
+ } else {
720
+ renderText(frame.terminalText || "");
721
+ }
722
+ root.dataset.replayPhase = String(phase);
723
+ }
724
+
725
+ window.__ptywrightReplaySetPhase = (nextPhase) => {
726
+ const parsed = Number(nextPhase);
727
+ phase = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : phase;
728
+ render();
729
+ };
730
+
731
+ window.addEventListener("resize", render);
732
+ root.addEventListener("focus", render);
733
+ root.focus();
734
+ render();
735
+ <\/script>
736
+ </body>
737
+ </html>`;
738
+ }
739
+ async function closeServer(server, sockets) {
740
+ const serverWithConnectionClosers = server;
741
+ serverWithConnectionClosers.closeIdleConnections?.();
742
+ await new Promise((resolveClose, rejectClose) => {
743
+ const forceCloseTimer = setTimeout(() => {
744
+ serverWithConnectionClosers.closeAllConnections?.();
745
+ for (const socket of sockets) socket.destroy();
746
+ }, 500);
747
+ const finishTimer = setTimeout(() => {
748
+ resolveClose();
749
+ }, 2e3);
750
+ server.close((error) => {
751
+ clearTimeout(forceCloseTimer);
752
+ clearTimeout(finishTimer);
753
+ if (error) rejectClose(error);
754
+ else resolveClose();
755
+ });
756
+ });
757
+ }
758
+ function escapeHtml$1(input) {
759
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
760
+ }
761
+ //#endregion
762
+ //#region src/agent/presets.ts
763
+ const COMMON_AGENT_MASKS = [
764
+ {
765
+ regex: "\\b\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?\\b",
766
+ replacement: "<timestamp>"
767
+ },
768
+ {
769
+ 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",
770
+ flags: "gi",
771
+ replacement: "<uuid>"
772
+ },
773
+ {
774
+ regex: "\\b(?:req|run|msg|chatcmpl|call|toolu|session)_[A-Za-z0-9_-]{8,}\\b",
775
+ replacement: "<id>"
776
+ },
777
+ {
778
+ regex: "\\b(?:[0-9a-f]{7,40})\\b",
779
+ flags: "gi",
780
+ replacement: "<hex>"
781
+ },
782
+ {
783
+ regex: "\\$\\d+(?:\\.\\d{2,6})?\\b",
784
+ replacement: "$<amount>"
785
+ },
786
+ {
787
+ regex: "\\b(?:\\d+\\.\\d+|\\d+)\\s*(?:s|ms|tokens?|tok)\\b",
788
+ flags: "gi",
789
+ replacement: "<metric>"
790
+ }
791
+ ];
792
+ const FLAVOR_MASKS = {
793
+ codex: [{
794
+ regex: "\\b(?:gpt|o)[A-Za-z0-9._:-]+\\b",
795
+ flags: "gi",
796
+ replacement: "<model>"
797
+ }, {
798
+ regex: "\\b(?:context|tokens?)\\s*[:=]\\s*[0-9,]+\\b",
799
+ flags: "gi",
800
+ replacement: "<token-count>"
801
+ }],
802
+ claude: [{
803
+ regex: "\\bclaude-[A-Za-z0-9._-]+\\b",
804
+ flags: "gi",
805
+ replacement: "<model>"
806
+ }, {
807
+ regex: "\\b(?:Opus|Sonnet|Haiku)\\s+[0-9.]+\\b",
808
+ flags: "gi",
809
+ replacement: "<model>"
810
+ }],
811
+ droid: [{
812
+ regex: "\\bdroidx?-[A-Za-z0-9._-]+\\b",
813
+ flags: "gi",
814
+ replacement: "<droid-id>"
815
+ }],
816
+ generic: []
817
+ };
818
+ const DEFAULT_VIEWPORTS = [{
819
+ name: "desktop",
820
+ width: 1280,
821
+ height: 820
822
+ }, {
823
+ name: "mobile",
824
+ width: 390,
825
+ height: 844,
826
+ isMobile: true,
827
+ hasTouch: true
828
+ }];
829
+ function resolveAgentFlavor(spec) {
830
+ const explicit = spec.launch.agentFlavor;
831
+ if (explicit) return explicit;
832
+ const command = spec.launch.command?.split(/[\\/]/).at(-1)?.toLowerCase() ?? "";
833
+ if (command === "codex" || command.startsWith("codex-")) return "codex";
834
+ if (command === "claude" || command === "claude-code" || command.startsWith("claude-")) return "claude";
835
+ if (command === "droid" || command === "droidx" || command.startsWith("droid")) return "droid";
836
+ return "generic";
837
+ }
838
+ function getAgentMaskPreset(flavor) {
839
+ return [...COMMON_AGENT_MASKS, ...FLAVOR_MASKS[flavor]];
840
+ }
841
+ function resolveAgentMasks(spec) {
842
+ return [...getAgentMaskPreset(resolveAgentFlavor(spec)), ...spec.defaults?.mask ?? []];
843
+ }
844
+ function createAgentTemplateSpec(flavor) {
845
+ const command = flavor === "droid" ? "droidx" : flavor === "generic" ? "agent" : flavor;
846
+ const name = flavor === "generic" ? "agent_browser_smoke" : `${flavor}_browser_smoke`;
847
+ return {
848
+ name,
849
+ artifactsDir: `.tmp/agent/${name}`,
850
+ snapshotDir: `tests/agent-snapshots/${name}`,
851
+ launch: {
852
+ mode: "aitty",
853
+ agentFlavor: flavor,
854
+ command,
855
+ args: [],
856
+ aitty: {
857
+ project: "ptywright",
858
+ label: command,
859
+ title: `${command} browser smoke`,
860
+ subtitle: "browser-hosted terminal agent regression",
861
+ theme: "light",
862
+ fontSize: 14,
863
+ waitForUrlMs: 15e3
864
+ }
865
+ },
866
+ viewports: DEFAULT_VIEWPORTS.map((viewport) => ({ ...viewport })),
867
+ defaults: {
868
+ timeoutMs: 45e3,
869
+ screenshot: true
870
+ },
871
+ steps: [{
872
+ type: "waitForStableDom",
873
+ timeoutMs: 45e3,
874
+ quietMs: 600,
875
+ intervalMs: 150
876
+ }, {
877
+ type: "snapshot",
878
+ name: "launch",
879
+ targets: [
880
+ "terminal",
881
+ "dom",
882
+ "screenshot"
883
+ ]
884
+ }]
885
+ };
886
+ }
887
+ //#endregion
888
+ //#region src/common/argv.ts
889
+ function formatArgv(argv) {
890
+ return argv.map(formatArg).join(" ");
891
+ }
892
+ function formatArg(arg) {
893
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(arg)) return arg;
894
+ return `'${arg.replace(/'/g, "'\\''")}'`;
895
+ }
896
+ //#endregion
897
+ //#region src/agent/run_record.ts
898
+ const AGENT_RUN_RECORD_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-agent-run.schema.json";
899
+ const agentRunModeSchema = z.enum(["live", "replay"]);
900
+ const agentRecordedStepSchema = z.object({
901
+ index: z.number().int().nonnegative(),
902
+ type: z.string().min(1),
903
+ label: z.string(),
904
+ durationMs: z.number().int().nonnegative(),
905
+ ok: z.boolean(),
906
+ error: z.string().optional()
907
+ }).strict();
908
+ const agentRunArtifactSchema = z.object({
909
+ name: z.string().min(1),
910
+ viewport: z.string().min(1),
911
+ kind: z.enum([
912
+ "terminal",
913
+ "dom",
914
+ "screenshot"
915
+ ]),
916
+ path: z.string().min(1),
917
+ baselinePath: z.string().min(1).optional(),
918
+ diffPath: z.string().min(1).optional(),
919
+ hash: z.string().min(1).optional(),
920
+ ok: z.boolean(),
921
+ error: z.string().optional()
922
+ }).strict();
923
+ const agentCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
924
+ const agentRunRecordCommandsSchema = z.object({
925
+ replay: agentCommandSchema,
926
+ updateSnapshots: agentCommandSchema
927
+ }).strict();
928
+ const agentRunRecordSchema = z.object({
929
+ $schema: z.string().optional(),
930
+ version: z.literal(1),
931
+ name: z.string().min(1),
932
+ ok: z.boolean(),
933
+ startedAt: z.string().min(1),
934
+ durationMs: z.number().int().nonnegative(),
935
+ mode: agentRunModeSchema,
936
+ spec: agentFlowSpecSchema.optional(),
937
+ flowPath: z.string().min(1).optional(),
938
+ artifactsDir: z.string().min(1),
939
+ snapshotDir: z.string().min(1),
940
+ reportPath: z.string().min(1),
941
+ cassettePath: z.string().min(1).optional(),
942
+ cassetteFrameCount: z.number().int().nonnegative(),
943
+ replayCommand: z.string().min(1),
944
+ commands: agentRunRecordCommandsSchema,
945
+ steps: z.array(agentRecordedStepSchema),
946
+ artifacts: z.array(agentRunArtifactSchema),
947
+ errors: z.array(z.string())
948
+ }).strict().superRefine((record, ctx) => {
949
+ if (!record.cassettePath && !record.flowPath && !record.spec) ctx.addIssue({
950
+ code: z.ZodIssueCode.custom,
951
+ message: "agent run record requires cassettePath, flowPath, or spec"
952
+ });
953
+ const replayCommand = formatArgv(record.commands.replay.argv);
954
+ if (record.replayCommand !== replayCommand) ctx.addIssue({
955
+ code: z.ZodIssueCode.custom,
956
+ path: ["replayCommand"],
957
+ message: "replayCommand must match commands.replay.argv"
958
+ });
959
+ if (!isReplayArgv(record.commands.replay.argv)) ctx.addIssue({
960
+ code: z.ZodIssueCode.custom,
961
+ path: [
962
+ "commands",
963
+ "replay",
964
+ "argv"
965
+ ],
966
+ message: "replay argv must be a ptywright agent replay command"
967
+ });
968
+ const expectedUpdateSnapshots = [...record.commands.replay.argv, "--update-snapshots"];
969
+ if (!sameArgv(record.commands.updateSnapshots.argv, expectedUpdateSnapshots)) ctx.addIssue({
970
+ code: z.ZodIssueCode.custom,
971
+ path: [
972
+ "commands",
973
+ "updateSnapshots",
974
+ "argv"
975
+ ],
976
+ message: "updateSnapshots argv must extend commands.replay.argv"
977
+ });
978
+ });
979
+ function normalizeAgentRunRecord(input) {
980
+ try {
981
+ const parsed = agentRunRecordSchema.parse(input);
982
+ return {
983
+ ...parsed,
984
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json",
985
+ spec: parsed.spec ? normalizeAgentFlowSpec(parsed.spec) : void 0
986
+ };
987
+ } catch (error) {
988
+ if (error instanceof z.ZodError) throw new Error(`invalid agent run record: ${formatZodIssues(error)}`);
989
+ throw error;
990
+ }
991
+ }
992
+ function formatAgentArgv(argv) {
993
+ return formatArgv(argv);
994
+ }
995
+ function sameArgv(left, right) {
996
+ return left.length === right.length && left.every((value, index) => value === right[index]);
997
+ }
998
+ function isReplayArgv(argv) {
999
+ return argv.length >= 4 && argv[0] === "ptywright" && argv[1] === "agent" && argv[2] === "replay";
1000
+ }
1001
+ function readAgentRunRecordPath(path) {
1002
+ return normalizeAgentRunRecord(JSON.parse(readFileSync(path, "utf8")));
1003
+ }
1004
+ function writeAgentRunRecordPath(path, record) {
1005
+ const normalized = normalizeAgentRunRecord({
1006
+ ...record,
1007
+ $schema: record.$schema ?? "https://ptywright.local/schemas/ptywright-agent-run.schema.json"
1008
+ });
1009
+ writeFileSync(path, JSON.stringify(normalized, null, 2) + "\n", "utf8");
1010
+ }
1011
+ function isAgentRunRecordLike(input) {
1012
+ if (typeof input !== "object" || input === null) return false;
1013
+ const candidate = input;
1014
+ return candidate.version === 1 && ("cassettePath" in candidate || "flowPath" in candidate || "spec" in candidate);
1015
+ }
1016
+ function formatZodIssues(error) {
1017
+ return error.issues.map((issue) => {
1018
+ return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
1019
+ }).join("; ");
1020
+ }
1021
+ //#endregion
1022
+ //#region src/agent/report.ts
1023
+ function writeAgentReport(path, result) {
1024
+ mkdirSync(dirname(path), { recursive: true });
1025
+ writeFileSync(path, renderAgentReportHtml(result), "utf8");
1026
+ }
1027
+ function renderAgentReportHtml(result) {
1028
+ const artifacts = result.artifacts.map((artifact) => renderArtifactRow(artifact, result.artifactsDir, result.reportPath)).join("\n");
1029
+ const viewportTabs = result.viewports.map((viewport) => `<span class="pill">${escapeHtml(viewport.name)} ${viewport.width}x${viewport.height}</span>`).join("");
1030
+ const status = result.ok ? "passed" : "failed";
1031
+ return `<!doctype html>
1032
+ <html lang="en">
1033
+ <head>
1034
+ <meta charset="utf-8" />
1035
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1036
+ <title>${escapeHtml(`${result.name} terminal-agent report`)}</title>
1037
+ <style>
1038
+ :root {
1039
+ color-scheme: light;
1040
+ --bg: oklch(97.5% 0.008 210);
1041
+ --ink: oklch(19% 0.018 230);
1042
+ --muted: oklch(48% 0.02 230);
1043
+ --line: oklch(86% 0.018 230);
1044
+ --panel: oklch(99% 0.006 210);
1045
+ --good: oklch(55% 0.15 155);
1046
+ --bad: oklch(58% 0.19 25);
1047
+ --focus: oklch(55% 0.14 235);
1048
+ font-family:
1049
+ ui-sans-serif,
1050
+ system-ui,
1051
+ -apple-system,
1052
+ BlinkMacSystemFont,
1053
+ "Segoe UI",
1054
+ sans-serif;
1055
+ }
1056
+ * { box-sizing: border-box; }
1057
+ body {
1058
+ margin: 0;
1059
+ background: var(--bg);
1060
+ color: var(--ink);
1061
+ }
1062
+ main {
1063
+ display: grid;
1064
+ gap: 24px;
1065
+ width: min(1180px, calc(100vw - 32px));
1066
+ margin: 0 auto;
1067
+ padding: 32px 0 48px;
1068
+ }
1069
+ header {
1070
+ display: grid;
1071
+ grid-template-columns: minmax(0, 1fr) auto;
1072
+ gap: 20px;
1073
+ align-items: start;
1074
+ border-bottom: 1px solid var(--line);
1075
+ padding-bottom: 24px;
1076
+ }
1077
+ h1 {
1078
+ margin: 0;
1079
+ font-size: 28px;
1080
+ line-height: 1.15;
1081
+ letter-spacing: 0;
1082
+ }
1083
+ h2 {
1084
+ margin: 0;
1085
+ font-size: 18px;
1086
+ line-height: 1.25;
1087
+ }
1088
+ .meta {
1089
+ display: flex;
1090
+ flex-wrap: wrap;
1091
+ gap: 8px;
1092
+ margin-top: 12px;
1093
+ }
1094
+ .pill,
1095
+ .status {
1096
+ display: inline-flex;
1097
+ min-height: 32px;
1098
+ align-items: center;
1099
+ border: 1px solid var(--line);
1100
+ border-radius: 999px;
1101
+ padding: 0 12px;
1102
+ color: var(--muted);
1103
+ font-size: 13px;
1104
+ }
1105
+ .status {
1106
+ border-color: ${result.ok ? "color-mix(in oklch, var(--good) 42%, var(--line))" : "color-mix(in oklch, var(--bad) 44%, var(--line))"};
1107
+ color: ${result.ok ? "var(--good)" : "var(--bad)"};
1108
+ font-weight: 700;
1109
+ }
1110
+ .panel {
1111
+ display: grid;
1112
+ gap: 16px;
1113
+ border: 1px solid var(--line);
1114
+ border-radius: 8px;
1115
+ background: var(--panel);
1116
+ padding: 18px;
1117
+ }
1118
+ .summary {
1119
+ display: grid;
1120
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1121
+ gap: 12px;
1122
+ }
1123
+ .metric {
1124
+ border: 1px solid var(--line);
1125
+ border-radius: 8px;
1126
+ padding: 14px;
1127
+ background: color-mix(in oklch, var(--panel) 82%, var(--bg));
1128
+ }
1129
+ .metric strong {
1130
+ display: block;
1131
+ font-size: 24px;
1132
+ line-height: 1.1;
1133
+ }
1134
+ .metric span {
1135
+ display: block;
1136
+ margin-top: 4px;
1137
+ color: var(--muted);
1138
+ font-size: 13px;
1139
+ }
1140
+ .artifacts {
1141
+ display: grid;
1142
+ gap: 10px;
1143
+ }
1144
+ .artifact {
1145
+ display: grid;
1146
+ grid-template-columns: 120px minmax(0, 1fr) auto;
1147
+ gap: 14px;
1148
+ align-items: center;
1149
+ border: 1px solid var(--line);
1150
+ border-radius: 8px;
1151
+ padding: 12px;
1152
+ background: var(--panel);
1153
+ }
1154
+ .artifact a {
1155
+ color: var(--focus);
1156
+ font-weight: 700;
1157
+ text-decoration: none;
1158
+ }
1159
+ .artifact code,
1160
+ pre {
1161
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
1162
+ }
1163
+ .artifact code {
1164
+ color: var(--muted);
1165
+ overflow-wrap: anywhere;
1166
+ }
1167
+ .badge {
1168
+ justify-self: start;
1169
+ border-radius: 999px;
1170
+ padding: 5px 9px;
1171
+ background: color-mix(in oklch, var(--line) 52%, transparent);
1172
+ color: var(--muted);
1173
+ font-size: 12px;
1174
+ font-weight: 700;
1175
+ }
1176
+ .badge.fail {
1177
+ background: color-mix(in oklch, var(--bad) 12%, var(--panel));
1178
+ color: var(--bad);
1179
+ }
1180
+ .badge.pass {
1181
+ background: color-mix(in oklch, var(--good) 12%, var(--panel));
1182
+ color: var(--good);
1183
+ }
1184
+ .commands {
1185
+ display: grid;
1186
+ gap: 10px;
1187
+ }
1188
+ .command {
1189
+ display: grid;
1190
+ gap: 5px;
1191
+ }
1192
+ .command span {
1193
+ color: var(--muted);
1194
+ font-size: 13px;
1195
+ font-weight: 700;
1196
+ }
1197
+ pre {
1198
+ overflow: auto;
1199
+ margin: 0;
1200
+ border-radius: 8px;
1201
+ background: oklch(20% 0.015 230);
1202
+ color: oklch(92% 0.012 230);
1203
+ padding: 14px;
1204
+ line-height: 1.5;
1205
+ }
1206
+ @media (max-width: 720px) {
1207
+ main {
1208
+ width: min(100vw - 20px, 1180px);
1209
+ padding-top: 18px;
1210
+ }
1211
+ header,
1212
+ .artifact {
1213
+ grid-template-columns: 1fr;
1214
+ }
1215
+ .status {
1216
+ justify-self: start;
1217
+ }
1218
+ }
1219
+ </style>
1220
+ </head>
1221
+ <body>
1222
+ <main>
1223
+ <header>
1224
+ <div>
1225
+ <h1>${escapeHtml(result.name)}</h1>
1226
+ <div class="meta">
1227
+ <span class="status">${status}</span>
1228
+ <span class="pill">${escapeHtml(result.mode)}</span>
1229
+ <span class="pill">${escapeHtml(result.agentFlavor)}</span>
1230
+ ${viewportTabs}
1231
+ <span class="pill">${escapeHtml(new Date(result.startedAt).toISOString())}</span>
1232
+ </div>
1233
+ </div>
1234
+ </header>
1235
+
1236
+ <section class="summary">
1237
+ <div class="metric"><strong>${result.steps.length}</strong><span>Recorded steps</span></div>
1238
+ <div class="metric"><strong>${result.artifacts.length}</strong><span>Snapshot artifacts</span></div>
1239
+ <div class="metric"><strong>${result.cassetteFrameCount}</strong><span>Cassette frames</span></div>
1240
+ <div class="metric"><strong>${result.durationMs}ms</strong><span>Wall time</span></div>
1241
+ </section>
1242
+
1243
+ <section class="panel">
1244
+ <h2>Commands</h2>
1245
+ <div class="commands">
1246
+ ${renderCommandBlock("replay", result.commands.replay.argv)}
1247
+ ${renderCommandBlock("update snapshots", result.commands.updateSnapshots.argv)}
1248
+ ${renderCommandBlock("inspect commands", [
1249
+ "ptywright",
1250
+ "agent",
1251
+ "commands",
1252
+ result.recordPath,
1253
+ "--json"
1254
+ ])}
1255
+ </div>
1256
+ <p><code>${escapeHtml(result.replaySourceCassettePath ?? result.cassettePath)}</code></p>
1257
+ </section>
1258
+
1259
+ <section class="panel">
1260
+ <h2>Terminal Agent Artifacts</h2>
1261
+ <div class="artifacts">
1262
+ ${artifacts || "<p>No artifacts were captured.</p>"}
1263
+ </div>
1264
+ </section>
1265
+ </main>
1266
+ </body>
1267
+ </html>`;
1268
+ }
1269
+ function renderCommandBlock(label, argv) {
1270
+ return `<div class="command">
1271
+ <span>${escapeHtml(label)}</span>
1272
+ <pre>${escapeHtml(formatAgentArgv(argv))}</pre>
1273
+ </div>`;
1274
+ }
1275
+ function renderArtifactRow(artifact, artifactsDir, reportPath) {
1276
+ const state = artifact.ok ? "pass" : "fail";
1277
+ const href = relativeHref(reportPath, artifact.path, artifactsDir);
1278
+ const baselineHref = artifact.baselinePath ? relativeHref(reportPath, artifact.baselinePath, artifactsDir) : "";
1279
+ const diffHref = artifact.diffPath ? relativeHref(reportPath, artifact.diffPath, artifactsDir) : "";
1280
+ return `<article class="artifact">
1281
+ <span class="badge ${state}">${state}</span>
1282
+ <div>
1283
+ ${artifact.kind === "screenshot" && artifact.path ? `<a href="${escapeAttribute(href)}">open image</a>` : `<a href="${escapeAttribute(href)}">${escapeHtml(artifact.kind)}</a>`}
1284
+ <div><code>${escapeHtml(artifact.viewport)} / ${escapeHtml(artifact.name)}</code></div>
1285
+ ${baselineHref ? `<div><code>baseline ${escapeHtml(baselineHref)}</code></div>` : ""}
1286
+ ${diffHref ? `<div><a href="${escapeAttribute(diffHref)}">diff</a></div>` : ""}
1287
+ ${artifact.error ? `<div><code>${escapeHtml(artifact.error)}</code></div>` : ""}
1288
+ </div>
1289
+ <code>${escapeHtml(artifact.hash ?? "")}</code>
1290
+ </article>`;
1291
+ }
1292
+ function relativeHref(_reportPath, targetPath, artifactsDir) {
1293
+ if (targetPath.startsWith(artifactsDir)) return targetPath.slice(artifactsDir.length).replace(/^\/+/, "");
1294
+ return targetPath;
1295
+ }
1296
+ function escapeHtml(input) {
1297
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1298
+ }
1299
+ function escapeAttribute(input) {
1300
+ return escapeHtml(input).replace(/'/g, "&#39;");
1301
+ }
1302
+ //#endregion
1303
+ //#region src/agent/spec_loader.ts
1304
+ async function loadAgentSpec(specPath) {
1305
+ const resolved = resolve(process.cwd(), specPath);
1306
+ if (resolved.endsWith(".json")) return {
1307
+ spec: normalizeAgentFlowSpec(JSON.parse(readFileSync(resolved, "utf8"))),
1308
+ path: resolved
1309
+ };
1310
+ const mod = await import(`${pathToFileURL(resolved).href}?t=${Date.now()}`);
1311
+ return {
1312
+ spec: normalizeAgentFlowSpec(mod.default ?? mod.spec),
1313
+ path: resolved
1314
+ };
1315
+ }
1316
+ //#endregion
1317
+ //#region src/agent/runner.ts
1318
+ async function runAgentSpecPath(specPath, options = {}) {
1319
+ return runAgentSpec((await loadAgentSpec(specPath)).spec, options);
1320
+ }
1321
+ async function replayAgentRecordPath(recordPath, options = {}) {
1322
+ const raw = JSON.parse(readFileSync(recordPath, "utf8"));
1323
+ if (isAgentCassetteLike(raw)) return replayAgentCassette(normalizeAgentCassette(raw), recordPath, options);
1324
+ const record = readAgentRunRecordPath(recordPath);
1325
+ if (record.cassettePath) {
1326
+ const cassettePath = isAbsolute(record.cassettePath) ? record.cassettePath : resolve(dirname(recordPath), record.cassettePath);
1327
+ return replayAgentCassette(readAgentCassettePath(cassettePath, record.spec), cassettePath, {
1328
+ ...options,
1329
+ artifactsDir: options.artifactsDir ?? join(dirname(recordPath), "replay")
1330
+ });
1331
+ }
1332
+ if (record.spec) return runAgentSpec(record.spec, options);
1333
+ if (!record.flowPath) throw new Error(`invalid agent run record: missing replay source in ${recordPath}`);
1334
+ return runAgentSpecPath(isAbsolute(record.flowPath) ? record.flowPath : resolve(dirname(recordPath), record.flowPath), options);
1335
+ }
1336
+ async function runAgentSpec(input, options = {}) {
1337
+ const startedAt = Date.now();
1338
+ const rootDir = options.rootDir ? resolve(process.cwd(), options.rootDir) : process.cwd();
1339
+ const spec = normalizeAgentFlowSpec(input);
1340
+ const name = sanitizeArtifactName(spec.name ?? "agent-flow");
1341
+ const artifactsDir = resolve(rootDir, options.artifactsDir ?? spec.artifactsDir ?? join(".tmp", "agent", name));
1342
+ const snapshotDir = resolve(rootDir, spec.snapshotDir ?? join("snapshots", name));
1343
+ const reportPath = join(artifactsDir, "index.html");
1344
+ const recordPath = join(artifactsDir, `${name}.agent-run.json`);
1345
+ const flowPath = join(artifactsDir, `${name}.flow.json`);
1346
+ const cassettePath = join(artifactsDir, `${name}.cassette.json`);
1347
+ const updateSnapshots = options.updateSnapshots ?? envTruthy(process.env.UPDATE_SNAPSHOTS);
1348
+ const cassette = options.replayCassette ?? createAgentCassette(name, spec);
1349
+ mkdirSync(artifactsDir, { recursive: true });
1350
+ mkdirSync(snapshotDir, { recursive: true });
1351
+ const replayArgv = [
1352
+ "ptywright",
1353
+ "agent",
1354
+ "replay",
1355
+ relative(process.cwd(), recordPath)
1356
+ ];
1357
+ const result = {
1358
+ ok: true,
1359
+ name,
1360
+ mode: options.replayCassette ? "replay" : "live",
1361
+ startedAt,
1362
+ durationMs: 0,
1363
+ artifactsDir,
1364
+ snapshotDir,
1365
+ reportPath,
1366
+ recordPath,
1367
+ flowPath,
1368
+ cassettePath,
1369
+ replaySourceCassettePath: options.replaySourceCassettePath,
1370
+ replayCommand: formatAgentArgv(replayArgv),
1371
+ commands: {
1372
+ replay: { argv: replayArgv },
1373
+ updateSnapshots: { argv: [...replayArgv, "--update-snapshots"] }
1374
+ },
1375
+ agentFlavor: resolveAgentFlavor(spec),
1376
+ viewports: spec.viewports ?? [],
1377
+ cassetteFrameCount: cassette.frames.length,
1378
+ steps: [],
1379
+ artifacts: [],
1380
+ errors: []
1381
+ };
1382
+ writeFileSync(flowPath, JSON.stringify(spec, null, 2) + "\n", "utf8");
1383
+ let browser = null;
1384
+ try {
1385
+ browser = await launchAgentBrowser({ headless: options.headless ?? true });
1386
+ for (const viewport of spec.viewports ?? []) await runViewport({
1387
+ browser,
1388
+ spec,
1389
+ viewport,
1390
+ rootDir,
1391
+ artifactsDir,
1392
+ snapshotDir,
1393
+ updateSnapshots,
1394
+ recordCassette: !options.replayCassette,
1395
+ cassette,
1396
+ result
1397
+ });
1398
+ } catch (error) {
1399
+ result.ok = false;
1400
+ result.errors.push(error instanceof Error ? error.message : String(error));
1401
+ } finally {
1402
+ await closeBrowserSafely(browser);
1403
+ result.durationMs = Date.now() - startedAt;
1404
+ if (options.replaySourceCassettePath) writeFileSync(cassettePath, readFileSync(options.replaySourceCassettePath, "utf8"), "utf8");
1405
+ else writeFileSync(cassettePath, JSON.stringify(cassette, null, 2) + "\n", "utf8");
1406
+ result.cassetteFrameCount = cassette.frames.length;
1407
+ writeRunRecord(result, spec);
1408
+ writeAgentReport(reportPath, result);
1409
+ writeRunManifest(result);
1410
+ }
1411
+ return result;
1412
+ }
1413
+ async function runViewport(args) {
1414
+ const { browser, spec, viewport, rootDir, artifactsDir, snapshotDir, updateSnapshots, recordCassette, cassette, result } = args;
1415
+ const launchMode = spec.launch.mode ?? (spec.launch.url ? "url" : "aitty");
1416
+ const session = launchMode === "aitty" ? await launchAittyBrowserSession(spec.launch, { rootDir }) : null;
1417
+ const url = launchMode === "url" ? spec.launch.url : session.url;
1418
+ const context = await browser.newContext({
1419
+ viewport: {
1420
+ width: viewport.width,
1421
+ height: viewport.height
1422
+ },
1423
+ deviceScaleFactor: viewport.deviceScaleFactor,
1424
+ isMobile: viewport.isMobile,
1425
+ hasTouch: viewport.hasTouch
1426
+ });
1427
+ const page = await context.newPage();
1428
+ const ctx = {
1429
+ spec,
1430
+ viewport,
1431
+ page,
1432
+ artifactsDir,
1433
+ snapshotDir,
1434
+ updateSnapshots,
1435
+ recordCassette,
1436
+ masks: resolveAgentMasks(spec),
1437
+ artifacts: result.artifacts,
1438
+ replay: Boolean(args.cassette && !args.recordCassette),
1439
+ cassette,
1440
+ nextReplayPhase: 0
1441
+ };
1442
+ try {
1443
+ await page.goto(url, {
1444
+ waitUntil: "domcontentloaded",
1445
+ timeout: spec.defaults?.timeoutMs ?? 3e4
1446
+ });
1447
+ await waitForTerminalRoot(page, spec.defaults?.timeoutMs ?? 3e4);
1448
+ await captureCassetteFrame(ctx, {
1449
+ stepIndex: null,
1450
+ stepType: "initial"
1451
+ });
1452
+ for (let i = 0; i < spec.steps.length; i += 1) {
1453
+ const step = spec.steps[i];
1454
+ const started = Date.now();
1455
+ try {
1456
+ await runStep(ctx, step);
1457
+ result.steps.push({
1458
+ index: i,
1459
+ type: step.type,
1460
+ label: formatStepLabel(step),
1461
+ durationMs: Date.now() - started,
1462
+ ok: true
1463
+ });
1464
+ await captureCassetteFrame(ctx, {
1465
+ stepIndex: i,
1466
+ stepType: step.type
1467
+ });
1468
+ } catch (error) {
1469
+ const message = error instanceof Error ? error.message : String(error);
1470
+ result.ok = false;
1471
+ result.errors.push(`${viewport.name} step ${i + 1} ${step.type}: ${message}`);
1472
+ result.steps.push({
1473
+ index: i,
1474
+ type: step.type,
1475
+ label: formatStepLabel(step),
1476
+ durationMs: Date.now() - started,
1477
+ ok: false,
1478
+ error: message
1479
+ });
1480
+ break;
1481
+ }
1482
+ }
1483
+ } finally {
1484
+ await withTimeout(context.close().catch(() => void 0), 5e3);
1485
+ await session?.close();
1486
+ }
1487
+ }
1488
+ async function replayAgentCassette(cassette, cassettePath, options) {
1489
+ const server = await startAgentCassetteServer(cassette);
1490
+ try {
1491
+ const replaySpec = structuredClone(cassette.spec);
1492
+ const replayCassette = structuredClone(cassette);
1493
+ return await runAgentSpec({
1494
+ ...replaySpec,
1495
+ launch: {
1496
+ mode: "url",
1497
+ url: server.url,
1498
+ agentFlavor: replaySpec.launch.agentFlavor
1499
+ }
1500
+ }, {
1501
+ ...options,
1502
+ artifactsDir: options.artifactsDir ?? join(dirname(cassettePath), "replay"),
1503
+ replayCassette,
1504
+ replaySourceCassettePath: cassettePath
1505
+ });
1506
+ } finally {
1507
+ await server.close();
1508
+ }
1509
+ }
1510
+ async function closeBrowserSafely(browser) {
1511
+ if (!browser) return;
1512
+ await withTimeout(browser.close().catch(() => void 0), 5e3);
1513
+ }
1514
+ async function withTimeout(promise, timeoutMs) {
1515
+ let timer;
1516
+ try {
1517
+ return await Promise.race([promise, new Promise((resolveTimeout) => {
1518
+ timer = setTimeout(resolveTimeout, timeoutMs);
1519
+ })]);
1520
+ } finally {
1521
+ if (timer) clearTimeout(timer);
1522
+ }
1523
+ }
1524
+ async function runStep(ctx, step) {
1525
+ const timeout = ctx.spec.defaults?.timeoutMs ?? 3e4;
1526
+ if (step.type === "waitForText") {
1527
+ await waitForTerminalText(ctx.page, {
1528
+ text: step.text,
1529
+ regex: step.regex,
1530
+ timeoutMs: step.timeoutMs ?? timeout
1531
+ });
1532
+ return;
1533
+ }
1534
+ if (step.type === "typeText") {
1535
+ await advanceReplayPhase(ctx);
1536
+ if (!ctx.replay) {
1537
+ await ctx.page.locator("[data-terminal-root]").first().click({ timeout });
1538
+ await ctx.page.keyboard.type(step.text, { delay: step.delayMs });
1539
+ if (step.enter) await ctx.page.keyboard.press("Enter");
1540
+ }
1541
+ return;
1542
+ }
1543
+ if (step.type === "pressKey") {
1544
+ await advanceReplayPhase(ctx);
1545
+ if (!ctx.replay) await ctx.page.keyboard.press(step.key);
1546
+ return;
1547
+ }
1548
+ if (step.type === "click") {
1549
+ await advanceReplayPhase(ctx);
1550
+ if (ctx.replay) return;
1551
+ if (step.selector) {
1552
+ await ctx.page.locator(step.selector).first().click({ timeout });
1553
+ return;
1554
+ }
1555
+ if (step.text) {
1556
+ await ctx.page.getByText(step.text, { exact: false }).first().click({ timeout });
1557
+ return;
1558
+ }
1559
+ await ctx.page.mouse.click(step.x, step.y);
1560
+ return;
1561
+ }
1562
+ if (step.type === "waitForStableDom") {
1563
+ await waitForStableDom(ctx.page, {
1564
+ timeoutMs: step.timeoutMs ?? timeout,
1565
+ quietMs: step.quietMs ?? 350,
1566
+ intervalMs: step.intervalMs ?? 100
1567
+ });
1568
+ return;
1569
+ }
1570
+ if (step.type === "snapshot") {
1571
+ await captureSnapshotStep(ctx, step);
1572
+ return;
1573
+ }
1574
+ if (step.type === "sleep") {
1575
+ if (!ctx.replay) await new Promise((resolveSleep) => setTimeout(resolveSleep, step.ms));
1576
+ return;
1577
+ }
1578
+ if (step.type === "mark") return;
1579
+ }
1580
+ async function captureCassetteFrame(ctx, frame) {
1581
+ if (!ctx.cassette || !ctx.recordCassette) return;
1582
+ const phase = ctx.nextReplayPhase;
1583
+ upsertAgentCassetteFrame(ctx.cassette, {
1584
+ viewport: ctx.viewport,
1585
+ phase,
1586
+ stepIndex: frame.stepIndex,
1587
+ stepType: frame.stepType,
1588
+ terminalText: normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks),
1589
+ dom: normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks),
1590
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
1591
+ });
1592
+ }
1593
+ async function advanceReplayPhase(ctx) {
1594
+ if (!ctx.replay) {
1595
+ ctx.nextReplayPhase += 1;
1596
+ return;
1597
+ }
1598
+ ctx.nextReplayPhase += 1;
1599
+ await ctx.page.evaluate((phase) => {
1600
+ window.__ptywrightReplaySetPhase?.(phase);
1601
+ }, ctx.nextReplayPhase);
1602
+ }
1603
+ async function captureSnapshotStep(ctx, step) {
1604
+ const targets = step.targets ?? [
1605
+ "terminal",
1606
+ "dom",
1607
+ ...ctx.spec.defaults?.screenshot ? ["screenshot"] : []
1608
+ ];
1609
+ const base = `${sanitizeArtifactName(ctx.viewport.name)}.${sanitizeArtifactName(step.name)}`;
1610
+ for (const target of targets) {
1611
+ if (target === "terminal") {
1612
+ const text = normalizeTerminalText(await readTerminalText(ctx.page), ctx.masks);
1613
+ await writeComparableArtifact(ctx, {
1614
+ name: step.name,
1615
+ kind: "terminal",
1616
+ relativePath: `${base}.terminal.txt`,
1617
+ baselineRelativePath: `${base}.terminal.snap.txt`,
1618
+ diffRelativePath: `${base}.terminal.diff.txt`,
1619
+ content: text + "\n",
1620
+ compare: step.compare ?? true
1621
+ });
1622
+ continue;
1623
+ }
1624
+ if (target === "dom") {
1625
+ const dom = normalizeDomSnapshot(await readTerminalDom(ctx.page), ctx.masks);
1626
+ await writeComparableArtifact(ctx, {
1627
+ name: step.name,
1628
+ kind: "dom",
1629
+ relativePath: `${base}.dom.html`,
1630
+ baselineRelativePath: `${base}.dom.snap.html`,
1631
+ diffRelativePath: `${base}.dom.diff.txt`,
1632
+ content: dom + "\n",
1633
+ compare: step.compare ?? true
1634
+ });
1635
+ continue;
1636
+ }
1637
+ const screenshotPath = join(ctx.artifactsDir, `${base}.png`);
1638
+ await ctx.page.screenshot({
1639
+ path: screenshotPath,
1640
+ fullPage: step.fullPage ?? false
1641
+ });
1642
+ ctx.artifacts.push({
1643
+ name: step.name,
1644
+ viewport: ctx.viewport.name,
1645
+ kind: "screenshot",
1646
+ path: screenshotPath,
1647
+ ok: true
1648
+ });
1649
+ }
1650
+ }
1651
+ async function writeComparableArtifact(ctx, artifact) {
1652
+ const artifactPath = join(ctx.artifactsDir, artifact.relativePath);
1653
+ const baselinePath = join(ctx.snapshotDir, artifact.baselineRelativePath);
1654
+ const diffPath = join(ctx.artifactsDir, artifact.diffRelativePath);
1655
+ const hash = shortHash(artifact.content);
1656
+ writeFileSync(artifactPath, artifact.content, "utf8");
1657
+ if (!artifact.compare) {
1658
+ ctx.artifacts.push({
1659
+ name: artifact.name,
1660
+ viewport: ctx.viewport.name,
1661
+ kind: artifact.kind,
1662
+ path: artifactPath,
1663
+ baselinePath,
1664
+ hash,
1665
+ ok: true
1666
+ });
1667
+ return;
1668
+ }
1669
+ if (ctx.updateSnapshots) {
1670
+ mkdirSync(dirname(baselinePath), { recursive: true });
1671
+ writeFileSync(baselinePath, artifact.content, "utf8");
1672
+ ctx.artifacts.push({
1673
+ name: artifact.name,
1674
+ viewport: ctx.viewport.name,
1675
+ kind: artifact.kind,
1676
+ path: artifactPath,
1677
+ baselinePath,
1678
+ hash,
1679
+ ok: true
1680
+ });
1681
+ return;
1682
+ }
1683
+ let baseline = null;
1684
+ try {
1685
+ baseline = readFileSync(baselinePath, "utf8");
1686
+ } catch {
1687
+ const message = `missing snapshot ${baselinePath}; rerun with --update-snapshots`;
1688
+ ctx.artifacts.push({
1689
+ name: artifact.name,
1690
+ viewport: ctx.viewport.name,
1691
+ kind: artifact.kind,
1692
+ path: artifactPath,
1693
+ baselinePath,
1694
+ hash,
1695
+ ok: false,
1696
+ error: message
1697
+ });
1698
+ throw new Error(message);
1699
+ }
1700
+ if (baseline !== artifact.content) {
1701
+ const message = `snapshot mismatch ${baselinePath}; rerun with --update-snapshots if intentional`;
1702
+ writeFileSync(diffPath, renderSnapshotDiff(baseline, artifact.content), "utf8");
1703
+ ctx.artifacts.push({
1704
+ name: artifact.name,
1705
+ viewport: ctx.viewport.name,
1706
+ kind: artifact.kind,
1707
+ path: artifactPath,
1708
+ baselinePath,
1709
+ diffPath,
1710
+ hash,
1711
+ ok: false,
1712
+ error: message
1713
+ });
1714
+ throw new Error(message);
1715
+ }
1716
+ ctx.artifacts.push({
1717
+ name: artifact.name,
1718
+ viewport: ctx.viewport.name,
1719
+ kind: artifact.kind,
1720
+ path: artifactPath,
1721
+ baselinePath,
1722
+ hash,
1723
+ ok: true
1724
+ });
1725
+ }
1726
+ function renderSnapshotDiff(expected, received) {
1727
+ const expectedLines = expected.split("\n");
1728
+ const receivedLines = received.split("\n");
1729
+ const max = Math.max(expectedLines.length, receivedLines.length);
1730
+ const out = ["--- expected", "+++ received"];
1731
+ for (let i = 0; i < max; i += 1) {
1732
+ const before = expectedLines[i];
1733
+ const after = receivedLines[i];
1734
+ if (before === after) {
1735
+ if (before !== void 0) out.push(` ${before}`);
1736
+ continue;
1737
+ }
1738
+ if (before !== void 0) out.push(`- ${before}`);
1739
+ if (after !== void 0) out.push(`+ ${after}`);
1740
+ }
1741
+ return out.join("\n") + "\n";
1742
+ }
1743
+ async function waitForTerminalRoot(page, timeoutMs) {
1744
+ await page.locator("[data-terminal-root]").first().waitFor({
1745
+ state: "attached",
1746
+ timeout: timeoutMs
1747
+ });
1748
+ }
1749
+ async function waitForTerminalText(page, args) {
1750
+ const started = Date.now();
1751
+ const matcher = args.regex ? new RegExp(args.regex) : null;
1752
+ while (Date.now() - started < args.timeoutMs) {
1753
+ const text = await readTerminalText(page);
1754
+ if (args.text && text.includes(args.text)) return;
1755
+ if (matcher?.test(text)) return;
1756
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
1757
+ }
1758
+ throw new Error(`timed out waiting for terminal text ${args.text ?? args.regex ?? ""}`);
1759
+ }
1760
+ async function waitForStableDom(page, args) {
1761
+ const started = Date.now();
1762
+ let last = "";
1763
+ let stableSince = Date.now();
1764
+ while (Date.now() - started < args.timeoutMs) {
1765
+ const current = await readTerminalDomIfPresent(page);
1766
+ if (current === null) {
1767
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
1768
+ continue;
1769
+ }
1770
+ if (current !== last) {
1771
+ last = current;
1772
+ stableSince = Date.now();
1773
+ } else if (Date.now() - stableSince >= args.quietMs) return;
1774
+ await new Promise((resolvePoll) => setTimeout(resolvePoll, args.intervalMs));
1775
+ }
1776
+ throw new Error(`timed out waiting for stable terminal DOM`);
1777
+ }
1778
+ async function readTerminalText(page) {
1779
+ const text = await page.evaluate(() => {
1780
+ const node = document.querySelector("[data-terminal-root]");
1781
+ if (!node) return null;
1782
+ const rows = Array.from(node.querySelectorAll(".term-grid .term-row"));
1783
+ if (rows.length > 0) return rows.map((row) => row.textContent ?? "").join("\n");
1784
+ return node.textContent ?? "";
1785
+ });
1786
+ if (text === null) throw new Error("terminal root is not attached");
1787
+ return text;
1788
+ }
1789
+ async function readTerminalDom(page) {
1790
+ const dom = await readTerminalDomIfPresent(page);
1791
+ if (dom === null) throw new Error("terminal root is not attached");
1792
+ return dom;
1793
+ }
1794
+ async function readTerminalDomIfPresent(page) {
1795
+ return page.evaluate(() => document.querySelector("[data-terminal-root]")?.innerHTML ?? null);
1796
+ }
1797
+ function writeRunRecord(result, spec) {
1798
+ const record = {
1799
+ $schema: AGENT_RUN_RECORD_SCHEMA_URL,
1800
+ version: 1,
1801
+ name: result.name,
1802
+ ok: result.ok,
1803
+ startedAt: new Date(result.startedAt).toISOString(),
1804
+ durationMs: result.durationMs,
1805
+ mode: result.mode,
1806
+ spec,
1807
+ flowPath: relative(dirname(result.recordPath), result.flowPath),
1808
+ artifactsDir: result.artifactsDir,
1809
+ snapshotDir: result.snapshotDir,
1810
+ reportPath: result.reportPath,
1811
+ cassettePath: relative(dirname(result.recordPath), result.cassettePath),
1812
+ cassetteFrameCount: result.cassetteFrameCount,
1813
+ replayCommand: result.replayCommand,
1814
+ commands: result.commands,
1815
+ steps: result.steps,
1816
+ artifacts: result.artifacts,
1817
+ errors: result.errors
1818
+ };
1819
+ writeAgentRunRecordPath(result.recordPath, record);
1820
+ }
1821
+ function writeRunManifest(result) {
1822
+ writeAgentManifestPath(agentManifestPath(result.artifactsDir), {
1823
+ kind: "run",
1824
+ ok: result.ok,
1825
+ rootDir: result.artifactsDir,
1826
+ primaryPath: result.recordPath,
1827
+ commands: result.commands,
1828
+ validation: {
1829
+ ok: result.ok,
1830
+ stages: [{
1831
+ name: "run",
1832
+ ok: result.ok,
1833
+ totalCount: result.artifacts.length,
1834
+ failureCount: result.artifacts.filter((artifact) => !artifact.ok).length
1835
+ }]
1836
+ },
1837
+ files: [
1838
+ {
1839
+ path: result.flowPath,
1840
+ kind: "flow",
1841
+ role: "flow"
1842
+ },
1843
+ {
1844
+ path: result.cassettePath,
1845
+ kind: "cassette",
1846
+ role: "cassette"
1847
+ },
1848
+ {
1849
+ path: result.recordPath,
1850
+ kind: "run-record",
1851
+ role: "record",
1852
+ ok: result.ok
1853
+ },
1854
+ {
1855
+ path: result.reportPath,
1856
+ kind: "report",
1857
+ role: "report",
1858
+ ok: result.ok
1859
+ },
1860
+ ...result.artifacts.flatMap((artifact) => [{
1861
+ path: artifact.path,
1862
+ kind: artifact.kind,
1863
+ role: "artifact",
1864
+ ok: artifact.ok
1865
+ }, {
1866
+ path: artifact.diffPath,
1867
+ kind: "diff",
1868
+ role: "diff",
1869
+ ok: artifact.ok
1870
+ }])
1871
+ ]
1872
+ });
1873
+ }
1874
+ function formatStepLabel(step) {
1875
+ if (step.type === "snapshot") return `snapshot ${step.name}`;
1876
+ if (step.type === "waitForText") return `wait ${step.text ?? step.regex ?? ""}`;
1877
+ if (step.type === "typeText") return `type ${step.text.slice(0, 24)}`;
1878
+ if (step.type === "pressKey") return `press ${step.key}`;
1879
+ if (step.type === "click") return `click ${step.selector ?? step.text ?? `${step.x},${step.y}`}`;
1880
+ if (step.type === "mark") return `mark ${step.label ?? ""}`;
1881
+ if (step.type === "sleep") return `sleep ${step.ms}ms`;
1882
+ return step.type;
1883
+ }
1884
+ function envTruthy(value) {
1885
+ return value === "1" || value === "true" || value === "yes";
1886
+ }
1887
+ function printAittyLaunchPlan(input) {
1888
+ const spec = normalizeAgentFlowSpec(input);
1889
+ if ((spec.launch.mode ?? "aitty") !== "aitty") return "launch.mode=url";
1890
+ const command = buildAittyExecCommand(spec.launch);
1891
+ return [command.file, ...command.args].join(" ");
1892
+ }
1893
+ function defaultSpecNameForPath(path) {
1894
+ return sanitizeArtifactName(basename(path, extname(path)));
1895
+ }
1896
+ //#endregion
1897
+ export { isAgentManifestLike as C, writeAgentManifestPath as E, agentManifestPath as S, validateAgentManifestFiles as T, normalizeAgentFlowSpec as _, runAgentSpecPath as a, launchAittyBrowserSession as b, agentRunModeSchema as c, readAgentRunRecordPath as d, writeAgentRunRecordPath as f, readAgentCassettePath as g, isAgentCassetteLike as h, runAgentSpec as i, formatAgentArgv as l, createAgentTemplateSpec as m, printAittyLaunchPlan as n, loadAgentSpec as o, formatArgv as p, replayAgentRecordPath as r, AGENT_RUN_RECORD_SCHEMA_URL as s, defaultSpecNameForPath as t, isAgentRunRecordLike as u, sanitizeArtifactName as v, readAgentManifestPath as w, AGENT_MANIFEST_FILE_NAME as x, launchAgentBrowser as y };