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