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.
- package/README.md +459 -116
- package/dist/agent.mjs +2 -0
- package/dist/bin/ptywright.mjs +6 -0
- package/dist/cli-DIUx2w6X.mjs +3587 -0
- package/dist/cli.mjs +2 -0
- package/{src/index.ts → dist/index.mjs} +7 -9
- package/dist/mcp.mjs +2 -0
- package/dist/pty-cassette.mjs +24 -0
- package/dist/pty_like-Cpkh_O9B.mjs +404 -0
- package/dist/runner-DzZlFrt1.mjs +1897 -0
- package/dist/runner-zApMYWZx.mjs +3257 -0
- package/dist/script.mjs +2 -0
- package/dist/server-VHuEWWj_.mjs +3068 -0
- package/dist/session.mjs +2 -0
- package/dist/terminal_session-DopC7Xg6.mjs +893 -0
- package/package.json +28 -21
- package/schemas/ptywright-agent-cassette.schema.json +57 -0
- package/schemas/ptywright-agent-check.schema.json +122 -0
- package/schemas/ptywright-agent-manifest.schema.json +107 -0
- package/schemas/ptywright-agent-promote.schema.json +146 -0
- package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
- package/schemas/ptywright-agent-run.schema.json +126 -0
- package/schemas/ptywright-agent.schema.json +182 -0
- package/schemas/ptywright-pty-cassette.schema.json +86 -0
- package/schemas/ptywright-script-manifest.schema.json +75 -0
- package/schemas/ptywright-script-run-summary.schema.json +114 -0
- package/schemas/ptywright-script.schema.json +55 -3
- package/skills/ptywright-testing/SKILL.md +53 -33
- package/bin/ptywright +0 -4
- package/src/cli.ts +0 -414
- package/src/generator/doc_parser.ts +0 -341
- package/src/generator/generate.ts +0 -161
- package/src/generator/index.ts +0 -10
- package/src/generator/script_generator.ts +0 -209
- package/src/generator/step_extractor.ts +0 -397
- package/src/mcp/http_server.ts +0 -174
- package/src/mcp/script_recording.ts +0 -238
- package/src/mcp/server.ts +0 -1348
- package/src/pty/bun_pty_adapter.ts +0 -34
- package/src/pty/bun_terminal_adapter.ts +0 -149
- package/src/pty/pty_adapter.ts +0 -31
- package/src/script/dsl.ts +0 -188
- package/src/script/module.ts +0 -43
- package/src/script/path.ts +0 -151
- package/src/script/run.ts +0 -108
- package/src/script/run_all.ts +0 -229
- package/src/script/runner.ts +0 -983
- package/src/script/schema.ts +0 -237
- package/src/script/steps/assert_snapshot_equals.ts +0 -21
- package/src/script/steps/index.ts +0 -2
- package/src/script/suite_report.ts +0 -626
- package/src/session/session_manager.ts +0 -145
- package/src/session/terminal_session.ts +0 -473
- package/src/terminal/ansi.ts +0 -142
- package/src/terminal/keys.ts +0 -180
- package/src/terminal/mask.ts +0 -70
- package/src/terminal/mouse.ts +0 -75
- package/src/terminal/snapshot.ts +0 -196
- package/src/terminal/style.ts +0 -121
- package/src/terminal/view.ts +0 -49
- package/src/trace/asciicast.ts +0 -20
- package/src/trace/asciinema_player_assets.ts +0 -44
- package/src/trace/cast_to_txt.ts +0 -116
- package/src/trace/recorder.ts +0 -110
- package/src/trace/report.ts +0 -2092
- package/src/types.ts +0 -86
- package/src/util/hash.ts +0 -8
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1298
|
+
}
|
|
1299
|
+
function escapeAttribute(input) {
|
|
1300
|
+
return escapeHtml(input).replace(/'/g, "'");
|
|
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 };
|