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,3068 @@
1
+ import { a as ensureAsciinemaPlayerAssets, i as generateTraceReportHtml, o as formatSnapshotView, r as scriptSchema, s as SessionManager, t as runScript } from "./runner-zApMYWZx.mjs";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { tmpdir } from "node:os";
9
+ //#region src/script/module.ts
10
+ function extractStepHandlers(mod) {
11
+ return mod.steps ?? mod.customSteps ?? mod.stepHandlers;
12
+ }
13
+ async function loadScriptModule(modulePath) {
14
+ const mod = await import(pathToFileURL(resolve(process.cwd(), modulePath)).href);
15
+ const script = mod.default ?? mod.script;
16
+ if (!script) throw new Error(`script module must export default or 'script': ${modulePath}`);
17
+ return {
18
+ script,
19
+ steps: extractStepHandlers(mod)
20
+ };
21
+ }
22
+ async function loadStepHandlersModule(modulePath) {
23
+ const steps = extractStepHandlers(await import(pathToFileURL(resolve(process.cwd(), modulePath)).href));
24
+ if (!steps) throw new Error(`steps module must export 'steps' (or customSteps/stepHandlers): ${modulePath}`);
25
+ return { steps };
26
+ }
27
+ //#endregion
28
+ //#region src/script/path.ts
29
+ async function runScriptPath(scriptPath, options) {
30
+ let scriptName;
31
+ let artifactsDir;
32
+ let castPath;
33
+ let reportPath;
34
+ try {
35
+ const ext = extname(scriptPath).toLowerCase();
36
+ const baseName = basename(scriptPath, extname(scriptPath));
37
+ const extraSteps = options?.stepsPath ? (await loadStepHandlersModule(options.stepsPath)).steps : void 0;
38
+ const loaded = await loadScriptInput(scriptPath, ext);
39
+ const stepsFromModule = loaded.steps;
40
+ const mergedSteps = stepsFromModule && extraSteps ? {
41
+ ...stepsFromModule,
42
+ ...extraSteps
43
+ } : stepsFromModule ?? extraSteps;
44
+ const built = loaded.script;
45
+ const withName = built && typeof built === "object" && !Array.isArray(built) && !("name" in built) ? {
46
+ ...built,
47
+ name: baseName
48
+ } : built;
49
+ const parsed = scriptSchema.parse(withName);
50
+ scriptName = parsed.name ?? baseName;
51
+ artifactsDir = resolveArtifactsDir(parsed, scriptName, options?.artifactsDir);
52
+ const trace = parsed.trace ?? {};
53
+ const saveCast = trace.saveCast ?? true;
54
+ const saveReport = trace.saveReport ?? true;
55
+ castPath = saveCast ? resolveArtifactPath(artifactsDir, trace.castPath ?? `${scriptName}.cast`) : void 0;
56
+ reportPath = saveReport ? resolveArtifactPath(artifactsDir, trace.reportPath ?? `${scriptName}.report.html`) : void 0;
57
+ try {
58
+ await runScript(parsed, {
59
+ artifactsDir,
60
+ updateGoldens: options?.updateGoldens,
61
+ steps: mergedSteps
62
+ });
63
+ return {
64
+ ok: true,
65
+ scriptName,
66
+ artifactsDir,
67
+ castPath,
68
+ reportPath
69
+ };
70
+ } catch (error) {
71
+ return {
72
+ ok: false,
73
+ error: error.message,
74
+ scriptName,
75
+ artifactsDir,
76
+ castPath,
77
+ reportPath,
78
+ failureArtifacts: {
79
+ lastTextPath: resolveArtifactPath(artifactsDir, "failure.last.txt"),
80
+ lastViewPath: resolveArtifactPath(artifactsDir, "failure.last.view.txt"),
81
+ stepPath: resolveArtifactPath(artifactsDir, "failure.step.json"),
82
+ errorPath: resolveArtifactPath(artifactsDir, "failure.error.txt")
83
+ }
84
+ };
85
+ }
86
+ } catch (error) {
87
+ return {
88
+ ok: false,
89
+ error: error.message,
90
+ scriptName,
91
+ artifactsDir,
92
+ castPath,
93
+ reportPath
94
+ };
95
+ }
96
+ }
97
+ async function loadScriptInput(scriptPath, ext) {
98
+ if (ext === ".json") {
99
+ const raw = await Bun.file(scriptPath).text();
100
+ return { script: JSON.parse(raw) };
101
+ }
102
+ const loaded = await loadScriptModule(scriptPath);
103
+ const script = loaded.script;
104
+ if (script && typeof script === "object" && "build" in script && typeof script.build === "function") return {
105
+ script: script.build(),
106
+ steps: loaded.steps
107
+ };
108
+ return {
109
+ script,
110
+ steps: loaded.steps
111
+ };
112
+ }
113
+ function resolveArtifactsDir(script, scriptName, override) {
114
+ if (override?.trim()) return resolve(override.trim());
115
+ if (script.artifactsDir?.trim()) return resolve(script.artifactsDir.trim());
116
+ return resolve(".tmp", "runs", scriptName);
117
+ }
118
+ function resolveArtifactPath(artifactsDir, path) {
119
+ if (isAbsolute(path)) return path;
120
+ return resolve(artifactsDir, path);
121
+ }
122
+ const SCRIPT_RUN_SUMMARY_FILE_NAME = "run.summary.json";
123
+ const scriptCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
124
+ const scriptRunSummaryCommandsSchema = z.object({
125
+ runAll: scriptCommandSchema,
126
+ updateGoldens: scriptCommandSchema
127
+ }).strict();
128
+ const scriptRunFailureArtifactsSchema = z.object({
129
+ lastTextPath: z.string().min(1),
130
+ lastViewPath: z.string().min(1),
131
+ stepPath: z.string().min(1),
132
+ errorPath: z.string().min(1)
133
+ }).strict();
134
+ const scriptRunSummaryEntrySchema = z.object({
135
+ filePath: z.string().min(1),
136
+ filePathRel: z.string().min(1),
137
+ scriptName: z.string().min(1),
138
+ ok: z.boolean(),
139
+ durationMs: z.number().int().nonnegative(),
140
+ artifactsDir: z.string().min(1).optional(),
141
+ reportPath: z.string().min(1).optional(),
142
+ castPath: z.string().min(1).optional(),
143
+ error: z.string().optional(),
144
+ failureArtifacts: scriptRunFailureArtifactsSchema.optional()
145
+ }).strict();
146
+ const scriptRunSummarySchema = z.object({
147
+ $schema: z.string().optional(),
148
+ version: z.literal(1),
149
+ ok: z.boolean(),
150
+ dir: z.string().min(1),
151
+ suiteDir: z.string().min(1),
152
+ commands: scriptRunSummaryCommandsSchema,
153
+ totalCount: z.number().int().nonnegative(),
154
+ failureCount: z.number().int().nonnegative(),
155
+ durationMs: z.number().int().nonnegative(),
156
+ reportPath: z.string().min(1),
157
+ summaryPath: z.string().min(1),
158
+ entries: z.array(scriptRunSummaryEntrySchema)
159
+ }).strict().superRefine((summary, ctx) => {
160
+ if (summary.totalCount !== summary.entries.length) ctx.addIssue({
161
+ code: z.ZodIssueCode.custom,
162
+ path: ["totalCount"],
163
+ message: "totalCount must equal entries.length"
164
+ });
165
+ const failureCount = summary.entries.filter((entry) => !entry.ok).length;
166
+ if (summary.failureCount !== failureCount) ctx.addIssue({
167
+ code: z.ZodIssueCode.custom,
168
+ path: ["failureCount"],
169
+ message: "failureCount must equal failed entries"
170
+ });
171
+ if (summary.ok !== (failureCount === 0)) ctx.addIssue({
172
+ code: z.ZodIssueCode.custom,
173
+ path: ["ok"],
174
+ message: "ok must be true only when failureCount is zero"
175
+ });
176
+ validateRunAllCommand(summary.commands.runAll.argv, summary, ctx);
177
+ if (!sameArgv$1(summary.commands.updateGoldens.argv, [...summary.commands.runAll.argv, "--update-goldens"])) ctx.addIssue({
178
+ code: z.ZodIssueCode.custom,
179
+ path: [
180
+ "commands",
181
+ "updateGoldens",
182
+ "argv"
183
+ ],
184
+ message: "updateGoldens argv must match runAll argv plus --update-goldens"
185
+ });
186
+ });
187
+ function makeScriptRunSummaryCommands(args) {
188
+ const runAll = [
189
+ "ptywright",
190
+ "run-all",
191
+ args.dir,
192
+ "--artifacts-root",
193
+ args.suiteDir,
194
+ ...args.stepsPath ? ["--steps", args.stepsPath] : []
195
+ ];
196
+ return {
197
+ runAll: { argv: runAll },
198
+ updateGoldens: { argv: [...runAll, "--update-goldens"] }
199
+ };
200
+ }
201
+ function normalizeScriptRunSummary(input) {
202
+ try {
203
+ const parsed = scriptRunSummarySchema.parse(input);
204
+ return {
205
+ ...parsed,
206
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-script-run-summary.schema.json"
207
+ };
208
+ } catch (error) {
209
+ if (error instanceof z.ZodError) throw new Error(`invalid script run summary: ${formatZodIssues$1(error)}`);
210
+ throw error;
211
+ }
212
+ }
213
+ function readScriptRunSummaryPath(path) {
214
+ return normalizeScriptRunSummary(JSON.parse(readFileSync(resolveScriptRunSummaryPath(path), "utf8")));
215
+ }
216
+ function writeScriptRunSummaryPath(path, summary) {
217
+ mkdirSync(dirname(path), { recursive: true });
218
+ writeFileSync(path, JSON.stringify(normalizeScriptRunSummary(summary), null, 2) + "\n", "utf8");
219
+ }
220
+ function resolveScriptRunSummaryPath(path) {
221
+ const resolved = resolve(process.cwd(), path);
222
+ if (statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) return join(resolved, SCRIPT_RUN_SUMMARY_FILE_NAME);
223
+ return resolved;
224
+ }
225
+ function validateRunAllCommand(argv, summary, ctx) {
226
+ if (argv[0] !== "ptywright" || argv[1] !== "run-all") {
227
+ ctx.addIssue({
228
+ code: z.ZodIssueCode.custom,
229
+ path: [
230
+ "commands",
231
+ "runAll",
232
+ "argv"
233
+ ],
234
+ message: "runAll argv must start with ptywright run-all"
235
+ });
236
+ return;
237
+ }
238
+ if (argv[2] !== summary.dir) ctx.addIssue({
239
+ code: z.ZodIssueCode.custom,
240
+ path: [
241
+ "commands",
242
+ "runAll",
243
+ "argv"
244
+ ],
245
+ message: "runAll argv must target summary dir"
246
+ });
247
+ const artifactsRootIndex = argv.indexOf("--artifacts-root");
248
+ if (artifactsRootIndex < 0 || argv[artifactsRootIndex + 1] !== summary.suiteDir) ctx.addIssue({
249
+ code: z.ZodIssueCode.custom,
250
+ path: [
251
+ "commands",
252
+ "runAll",
253
+ "argv"
254
+ ],
255
+ message: "runAll argv must target summary suiteDir"
256
+ });
257
+ }
258
+ function sameArgv$1(left, right) {
259
+ return left.length === right.length && left.every((value, index) => value === right[index]);
260
+ }
261
+ function formatZodIssues$1(error) {
262
+ return error.issues.map((issue) => {
263
+ return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
264
+ }).join("; ");
265
+ }
266
+ //#endregion
267
+ //#region src/script/manifest.ts
268
+ const SCRIPT_MANIFEST_SCHEMA_URL = "https://ptywright.local/schemas/ptywright-script-manifest.schema.json";
269
+ const SCRIPT_MANIFEST_FILE_NAME = "ptywright-script.manifest.json";
270
+ const scriptManifestFileKindSchema = z.enum([
271
+ "run-summary",
272
+ "report",
273
+ "cast",
274
+ "data",
275
+ "failure"
276
+ ]);
277
+ const scriptManifestCommandSchema = z.object({ argv: z.array(z.string().min(1)).min(1) }).strict();
278
+ const scriptManifestFileSchema = z.object({
279
+ path: z.string().min(1),
280
+ kind: scriptManifestFileKindSchema,
281
+ role: z.string().min(1).optional(),
282
+ ok: z.boolean().optional(),
283
+ bytes: z.number().int().nonnegative(),
284
+ sha256: z.string().regex(/^[a-f0-9]{64}$/)
285
+ }).strict();
286
+ const scriptManifestSchema = z.object({
287
+ $schema: z.string().optional(),
288
+ version: z.literal(1),
289
+ kind: z.literal("run-suite"),
290
+ ok: z.boolean(),
291
+ generatedAt: z.string().min(1),
292
+ rootDir: z.string().min(1),
293
+ primaryPath: z.string().min(1),
294
+ commands: z.record(scriptManifestCommandSchema),
295
+ totalCount: z.number().int().nonnegative(),
296
+ failureCount: z.number().int().nonnegative(),
297
+ files: z.array(scriptManifestFileSchema)
298
+ }).strict().superRefine((manifest, ctx) => {
299
+ const seen = /* @__PURE__ */ new Set();
300
+ for (const file of manifest.files) {
301
+ if (seen.has(file.path)) ctx.addIssue({
302
+ code: z.ZodIssueCode.custom,
303
+ path: ["files"],
304
+ message: `duplicate manifest file path: ${file.path}`
305
+ });
306
+ seen.add(file.path);
307
+ }
308
+ });
309
+ function scriptManifestPath(rootDir) {
310
+ return join(rootDir, SCRIPT_MANIFEST_FILE_NAME);
311
+ }
312
+ function resolveScriptManifestPath(path) {
313
+ const resolved = resolve(process.cwd(), path);
314
+ if (statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) return scriptManifestPath(resolved);
315
+ return resolved;
316
+ }
317
+ function createScriptManifest(options) {
318
+ return normalizeScriptManifest({
319
+ $schema: SCRIPT_MANIFEST_SCHEMA_URL,
320
+ version: 1,
321
+ kind: "run-suite",
322
+ ok: options.ok,
323
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
324
+ rootDir: options.rootDir,
325
+ primaryPath: options.primaryPath,
326
+ commands: options.commands,
327
+ totalCount: options.totalCount,
328
+ failureCount: options.failureCount,
329
+ files: collectManifestFiles(options.files, options.rootDir)
330
+ });
331
+ }
332
+ function writeScriptManifestPath(path, options) {
333
+ const manifest = createScriptManifest(options);
334
+ mkdirSync(dirname(path), { recursive: true });
335
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf8");
336
+ return manifest;
337
+ }
338
+ function readScriptManifestPath(path) {
339
+ return normalizeScriptManifest(JSON.parse(readFileSync(path, "utf8")));
340
+ }
341
+ function normalizeScriptManifest(input) {
342
+ try {
343
+ const parsed = scriptManifestSchema.parse(input);
344
+ return {
345
+ ...parsed,
346
+ $schema: parsed.$schema ?? "https://ptywright.local/schemas/ptywright-script-manifest.schema.json"
347
+ };
348
+ } catch (error) {
349
+ if (error instanceof z.ZodError) throw new Error(`invalid script manifest: ${formatZodIssues(error)}`);
350
+ throw error;
351
+ }
352
+ }
353
+ function validateScriptManifest(manifest, manifestPath) {
354
+ validateScriptManifestFiles(manifest, manifestPath);
355
+ validateScriptManifestCommands(manifest, manifestPath);
356
+ }
357
+ function validateScriptManifestFiles(manifest, manifestPath) {
358
+ const failures = [];
359
+ const baseDir = manifestPath ? dirname(resolve(process.cwd(), manifestPath)) : resolve(process.cwd(), manifest.rootDir);
360
+ for (const file of manifest.files) {
361
+ let current = null;
362
+ try {
363
+ current = readManifestFile(file, baseDir);
364
+ } catch (error) {
365
+ failures.push(`${file.path}: ${error instanceof Error ? error.message : String(error)}`);
366
+ continue;
367
+ }
368
+ if (current.bytes !== file.bytes) failures.push(`${file.path}: bytes ${current.bytes} !== ${file.bytes}`);
369
+ if (current.sha256 !== file.sha256) failures.push(`${file.path}: sha256 ${current.sha256} !== ${file.sha256}`);
370
+ }
371
+ if (failures.length > 0) throw new Error(`invalid script manifest files: ${failures.join("; ")}`);
372
+ }
373
+ function validateScriptManifestCommands(manifest, manifestPath) {
374
+ const failures = [];
375
+ const summaryPath = resolveManifestPrimaryPath(manifest, manifestPath);
376
+ try {
377
+ const summary = readScriptRunSummaryPath(summaryPath);
378
+ compareCommandMaps(manifest.commands, summary.commands, failures);
379
+ } catch (error) {
380
+ failures.push(`unable to read manifest primary summary ${summaryPath}: ${error instanceof Error ? error.message : String(error)}`);
381
+ }
382
+ if (failures.length > 0) throw new Error(`invalid script manifest commands: ${failures.join("; ")}`);
383
+ }
384
+ function findScriptSummaryManifest(summaryPath) {
385
+ const resolvedSummaryPath = resolve(process.cwd(), summaryPath);
386
+ const manifestPath = scriptManifestPath(dirname(resolvedSummaryPath));
387
+ if (!existsSync(manifestPath)) return null;
388
+ let manifest;
389
+ try {
390
+ manifest = readScriptManifestPath(manifestPath);
391
+ } catch {
392
+ return null;
393
+ }
394
+ if (samePath(resolveManifestPrimaryPath(manifest, manifestPath), resolvedSummaryPath)) return {
395
+ manifest,
396
+ manifestPath
397
+ };
398
+ return null;
399
+ }
400
+ function relocateScriptManifestCommands(manifest, manifestPath) {
401
+ return Object.fromEntries(Object.entries(manifest.commands).map(([name, command]) => [name, { argv: relocateScriptCommandArgv(command.argv, dirname(resolve(process.cwd(), manifestPath))) }]));
402
+ }
403
+ function resolveManifestPrimaryPath(manifest, manifestPath) {
404
+ const baseDir = manifestPath ? dirname(resolve(process.cwd(), manifestPath)) : resolve(process.cwd(), manifest.rootDir);
405
+ const primaryFile = manifest.files.find((file) => file.kind === "run-summary" && file.role === "summary") ?? manifest.files.find((file) => file.kind === "run-summary");
406
+ if (primaryFile) return isAbsolute(primaryFile.path) ? primaryFile.path : join(baseDir, primaryFile.path);
407
+ return isAbsolute(manifest.primaryPath) ? manifest.primaryPath : resolve(process.cwd(), manifest.primaryPath);
408
+ }
409
+ function collectManifestFiles(files, rootDir) {
410
+ const out = [];
411
+ const seen = /* @__PURE__ */ new Set();
412
+ const rootAbs = resolve(process.cwd(), rootDir);
413
+ for (const file of files) {
414
+ if (!file.path || seen.has(file.path)) continue;
415
+ seen.add(file.path);
416
+ try {
417
+ out.push(readManifestFile(file, rootAbs, { portableRoot: true }));
418
+ } catch {}
419
+ }
420
+ return out.sort((a, b) => a.path.localeCompare(b.path));
421
+ }
422
+ function readManifestFile(file, baseDir, options = {}) {
423
+ if (!file.path) throw new Error("missing file path");
424
+ const absPath = isAbsolute(file.path) ? file.path : resolve(options.portableRoot ? process.cwd() : baseDir, file.path);
425
+ if (!statSync(absPath).isFile()) throw new Error("not a file");
426
+ const bytes = readFileSync(absPath);
427
+ return {
428
+ path: options.portableRoot ? portableManifestPath(absPath, baseDir) : file.path,
429
+ kind: file.kind,
430
+ role: file.role,
431
+ ok: file.ok,
432
+ bytes: bytes.byteLength,
433
+ sha256: createHash("sha256").update(bytes).digest("hex")
434
+ };
435
+ }
436
+ function relocateScriptCommandArgv(argv, rootDir) {
437
+ if (argv[0] !== "ptywright" || argv[1] !== "run-all") return [...argv];
438
+ return setArgvFlag([...argv], "--artifacts-root", portableCliPath(rootDir));
439
+ }
440
+ function setArgvFlag(argv, flag, value) {
441
+ const index = argv.indexOf(flag);
442
+ if (index >= 0) return [
443
+ ...argv.slice(0, index + 1),
444
+ value,
445
+ ...argv.slice(index + 2)
446
+ ];
447
+ return [
448
+ ...argv,
449
+ flag,
450
+ value
451
+ ];
452
+ }
453
+ function compareCommandMaps(actual, expected, failures) {
454
+ const actualNames = Object.keys(actual).sort();
455
+ const expectedNames = Object.keys(expected).sort();
456
+ if (!sameStringList(actualNames, expectedNames)) failures.push(`manifest command names must match primary summary commands: ${expectedNames.join(",")}`);
457
+ for (const [name, command] of Object.entries(expected)) {
458
+ const actualCommand = actual[name];
459
+ if (!actualCommand) continue;
460
+ if (!sameArgv(actualCommand.argv, command.argv)) failures.push(`command ${name} argv must match primary summary`);
461
+ }
462
+ }
463
+ function portableManifestPath(absPath, rootAbs) {
464
+ const rel = relative(rootAbs, absPath);
465
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
466
+ return absPath;
467
+ }
468
+ function portableCliPath(path) {
469
+ const abs = resolve(process.cwd(), path);
470
+ const rel = relative(process.cwd(), abs);
471
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
472
+ return abs;
473
+ }
474
+ function sameArgv(left, right) {
475
+ return left.length === right.length && left.every((value, index) => value === right[index]);
476
+ }
477
+ function sameStringList(left, right) {
478
+ return left.length === right.length && left.every((value, index) => value === right[index]);
479
+ }
480
+ function samePath(left, right) {
481
+ return resolve(process.cwd(), left) === resolve(process.cwd(), right);
482
+ }
483
+ function formatZodIssues(error) {
484
+ return error.issues.map((issue) => {
485
+ return `${issue.path.length ? issue.path.join(".") : "<root>"}: ${issue.message}`;
486
+ }).join("; ");
487
+ }
488
+ //#endregion
489
+ //#region src/script/suite_report.ts
490
+ function writeSuiteReportArtifacts(args) {
491
+ mkdirSync(args.suiteDir, { recursive: true });
492
+ const reportPath = join(args.suiteDir, "index.html");
493
+ const summaryPath = join(args.suiteDir, "run.summary.json");
494
+ const failures = args.entries.filter((e) => !e.result.ok);
495
+ const summary = {
496
+ version: 1,
497
+ ok: failures.length === 0,
498
+ dir: args.dir,
499
+ suiteDir: args.suiteDir,
500
+ commands: makeScriptRunSummaryCommands(args),
501
+ totalCount: args.entries.length,
502
+ failureCount: failures.length,
503
+ durationMs: args.durationMs,
504
+ reportPath,
505
+ summaryPath,
506
+ entries: args.entries.map((entry) => {
507
+ const filePathRel = normalizePath(relative(process.cwd(), entry.filePath));
508
+ const scriptName = entry.result.scriptName ?? basename(entry.filePath).replace(/\.(json|ts)$/i, "");
509
+ const common = {
510
+ filePath: entry.filePath,
511
+ filePathRel,
512
+ scriptName,
513
+ ok: entry.result.ok,
514
+ durationMs: entry.durationMs,
515
+ artifactsDir: entry.result.artifactsDir,
516
+ reportPath: entry.result.reportPath,
517
+ castPath: entry.result.castPath
518
+ };
519
+ if (entry.result.ok) return common;
520
+ return {
521
+ ...common,
522
+ ok: false,
523
+ error: entry.result.error,
524
+ failureArtifacts: entry.result.failureArtifacts
525
+ };
526
+ })
527
+ };
528
+ writeScriptRunSummaryPath(summaryPath, summary);
529
+ writeFileSync(reportPath, renderSuiteReportHtml({
530
+ reportPath,
531
+ summaryPath,
532
+ summary
533
+ }), "utf8");
534
+ writeSuiteManifest({
535
+ suiteDir: args.suiteDir,
536
+ summary
537
+ });
538
+ return {
539
+ reportPath,
540
+ summaryPath
541
+ };
542
+ }
543
+ function writeSuiteManifest(args) {
544
+ writeScriptManifestPath(scriptManifestPath(args.suiteDir), {
545
+ ok: args.summary.ok,
546
+ rootDir: args.suiteDir,
547
+ primaryPath: args.summary.summaryPath,
548
+ commands: args.summary.commands,
549
+ totalCount: args.summary.totalCount,
550
+ failureCount: args.summary.failureCount,
551
+ files: [
552
+ {
553
+ path: args.summary.summaryPath,
554
+ kind: "run-summary",
555
+ role: "summary",
556
+ ok: args.summary.ok
557
+ },
558
+ {
559
+ path: args.summary.reportPath,
560
+ kind: "report",
561
+ role: "suite-report",
562
+ ok: args.summary.ok
563
+ },
564
+ ...args.summary.entries.flatMap((entry) => [
565
+ {
566
+ path: entry.reportPath,
567
+ kind: "report",
568
+ role: "entry-report",
569
+ ok: entry.ok
570
+ },
571
+ {
572
+ path: entry.castPath,
573
+ kind: "cast",
574
+ role: "cast",
575
+ ok: entry.ok
576
+ },
577
+ {
578
+ path: entry.artifactsDir ? join(entry.artifactsDir, "test.data.js") : void 0,
579
+ kind: "data",
580
+ role: "test-data",
581
+ ok: entry.ok
582
+ },
583
+ ...!entry.ok && entry.failureArtifacts ? [
584
+ {
585
+ path: entry.failureArtifacts.lastTextPath,
586
+ kind: "failure",
587
+ role: "last-text",
588
+ ok: false
589
+ },
590
+ {
591
+ path: entry.failureArtifacts.lastViewPath,
592
+ kind: "failure",
593
+ role: "last-view",
594
+ ok: false
595
+ },
596
+ {
597
+ path: entry.failureArtifacts.stepPath,
598
+ kind: "failure",
599
+ role: "step",
600
+ ok: false
601
+ },
602
+ {
603
+ path: entry.failureArtifacts.errorPath,
604
+ kind: "failure",
605
+ role: "error",
606
+ ok: false
607
+ }
608
+ ] : []
609
+ ])
610
+ ]
611
+ });
612
+ }
613
+ function renderSuiteReportHtml(args) {
614
+ const title = "ptywright script report";
615
+ const resultLabel = args.summary.ok ? "PASS" : "FAIL";
616
+ const resultClass = args.summary.ok ? "pass" : "fail";
617
+ const summaryHref = relativeHref(args.reportPath, args.summaryPath);
618
+ const uiData = {
619
+ version: 1,
620
+ summary: {
621
+ ok: args.summary.ok,
622
+ dir: args.summary.dir,
623
+ suiteDir: args.summary.suiteDir,
624
+ totalCount: args.summary.totalCount,
625
+ failureCount: args.summary.failureCount,
626
+ durationMs: args.summary.durationMs,
627
+ summaryHref
628
+ },
629
+ entries: args.summary.entries.map((entry) => {
630
+ const reportHref = entry.reportPath ? relativeHref(args.reportPath, entry.reportPath) : null;
631
+ const castHref = entry.castPath ? relativeHref(args.reportPath, entry.castPath) : null;
632
+ const playHref = reportHref ? `${reportHref}#cast-playback` : null;
633
+ const lastHref = !entry.ok && entry.failureArtifacts?.lastViewPath ? relativeHref(args.reportPath, entry.failureArtifacts.lastViewPath) : null;
634
+ const errorHref = !entry.ok && entry.failureArtifacts?.errorPath ? relativeHref(args.reportPath, entry.failureArtifacts.errorPath) : null;
635
+ const dataKey = entry.artifactsDir ? basename(entry.artifactsDir) : null;
636
+ const dataHref = entry.artifactsDir && dataKey ? relativeHref(args.reportPath, join(entry.artifactsDir, "test.data.js")) : null;
637
+ return {
638
+ id: entry.filePathRel,
639
+ ok: entry.ok,
640
+ scriptName: entry.scriptName,
641
+ filePathRel: entry.filePathRel,
642
+ durationMs: entry.durationMs,
643
+ error: entry.ok ? null : entry.error ?? null,
644
+ artifactsDir: entry.artifactsDir ?? null,
645
+ dataKey,
646
+ hrefs: {
647
+ report: reportHref,
648
+ play: playHref,
649
+ cast: castHref,
650
+ last: lastHref,
651
+ error: errorHref,
652
+ data: dataHref
653
+ }
654
+ };
655
+ })
656
+ };
657
+ return `<!doctype html>
658
+ <html lang="en">
659
+ <head>
660
+ <meta charset="utf-8" />
661
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
662
+ <title>${escapeHtml(title)}</title>
663
+ <style>
664
+ :root {
665
+ color-scheme: light dark;
666
+ }
667
+ body {
668
+ margin: 0;
669
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
670
+ Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
671
+ line-height: 1.4;
672
+ }
673
+ a {
674
+ color: inherit;
675
+ }
676
+ header {
677
+ padding: 16px;
678
+ border-bottom: 1px solid color-mix(in oklab, currentColor 20%, transparent);
679
+ }
680
+ header h1 {
681
+ margin: 0 0 8px 0;
682
+ font-size: 18px;
683
+ }
684
+ header .badges {
685
+ display: flex;
686
+ gap: 8px;
687
+ flex-wrap: wrap;
688
+ margin: 6px 0 10px 0;
689
+ }
690
+ .badge {
691
+ display: inline-flex;
692
+ align-items: center;
693
+ border-radius: 999px;
694
+ padding: 2px 10px;
695
+ font-size: 12px;
696
+ border: 1px solid color-mix(in oklab, currentColor 16%, transparent);
697
+ background: color-mix(in oklab, currentColor 6%, transparent);
698
+ }
699
+ .badge.pass {
700
+ background: color-mix(in oklab, #16a34a 18%, transparent);
701
+ border-color: color-mix(in oklab, #16a34a 45%, transparent);
702
+ }
703
+ .badge.fail {
704
+ background: color-mix(in oklab, #ef4444 18%, transparent);
705
+ border-color: color-mix(in oklab, #ef4444 45%, transparent);
706
+ }
707
+ header .meta {
708
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
709
+ "Liberation Mono", "Courier New", monospace;
710
+ font-size: 12px;
711
+ opacity: 0.8;
712
+ white-space: pre-wrap;
713
+ }
714
+ main {
715
+ display: grid;
716
+ grid-template-columns: 360px 1fr;
717
+ min-height: calc(100vh - 110px);
718
+ }
719
+ aside {
720
+ border-right: 1px solid color-mix(in oklab, currentColor 14%, transparent);
721
+ padding: 12px;
722
+ }
723
+ section {
724
+ padding: 16px;
725
+ }
726
+ .controls {
727
+ display: flex;
728
+ gap: 8px;
729
+ flex-wrap: wrap;
730
+ align-items: center;
731
+ margin-bottom: 10px;
732
+ }
733
+ .input {
734
+ width: 100%;
735
+ box-sizing: border-box;
736
+ padding: 8px 10px;
737
+ border-radius: 10px;
738
+ border: 1px solid color-mix(in oklab, currentColor 16%, transparent);
739
+ background: color-mix(in oklab, currentColor 4%, transparent);
740
+ color: inherit;
741
+ }
742
+ .chip {
743
+ cursor: pointer;
744
+ user-select: none;
745
+ }
746
+ .chip[aria-pressed="true"] {
747
+ background: color-mix(in oklab, #0ea5e9 18%, transparent);
748
+ border-color: color-mix(in oklab, #0ea5e9 45%, transparent);
749
+ }
750
+ .list {
751
+ list-style: none;
752
+ padding: 0;
753
+ margin: 0;
754
+ display: flex;
755
+ flex-direction: column;
756
+ gap: 6px;
757
+ }
758
+ .item {
759
+ border: 1px solid color-mix(in oklab, currentColor 14%, transparent);
760
+ border-radius: 12px;
761
+ padding: 10px;
762
+ cursor: pointer;
763
+ background: color-mix(in oklab, currentColor 2%, transparent);
764
+ }
765
+ .item:hover {
766
+ background: color-mix(in oklab, currentColor 6%, transparent);
767
+ }
768
+ .item[aria-selected="true"] {
769
+ border-color: color-mix(in oklab, #0ea5e9 55%, transparent);
770
+ background: color-mix(in oklab, #0ea5e9 10%, transparent);
771
+ }
772
+ .item .top {
773
+ display: flex;
774
+ gap: 8px;
775
+ align-items: center;
776
+ }
777
+ .item .name {
778
+ font-weight: 600;
779
+ overflow: hidden;
780
+ text-overflow: ellipsis;
781
+ white-space: nowrap;
782
+ }
783
+ .item .sub {
784
+ margin-top: 6px;
785
+ display: flex;
786
+ gap: 8px;
787
+ flex-wrap: wrap;
788
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
789
+ "Liberation Mono", "Courier New", monospace;
790
+ font-size: 12px;
791
+ opacity: 0.8;
792
+ }
793
+ .mono {
794
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
795
+ "Liberation Mono", "Courier New", monospace;
796
+ font-size: 12px;
797
+ }
798
+ .error {
799
+ color: color-mix(in oklab, #ef4444 70%, currentColor);
800
+ }
801
+ .kv {
802
+ display: grid;
803
+ grid-template-columns: 110px 1fr;
804
+ gap: 8px 12px;
805
+ margin-top: 12px;
806
+ }
807
+ .kv .k {
808
+ opacity: 0.75;
809
+ }
810
+ .links {
811
+ display: flex;
812
+ gap: 10px;
813
+ flex-wrap: wrap;
814
+ margin-top: 10px;
815
+ }
816
+ .muted {
817
+ opacity: 0.75;
818
+ }
819
+ @media (max-width: 920px) {
820
+ main {
821
+ grid-template-columns: 1fr;
822
+ }
823
+ aside {
824
+ border-right: none;
825
+ border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
826
+ }
827
+ }
828
+ </style>
829
+ </head>
830
+ <body>
831
+ <header>
832
+ <h1>${escapeHtml(title)}</h1>
833
+ <div class="badges">
834
+ <span class="badge ${resultClass}">result=${escapeHtml(resultLabel)}</span>
835
+ <span class="badge">count=${args.summary.totalCount}</span>
836
+ <span class="badge">failures=${args.summary.failureCount}</span>
837
+ <span class="badge">duration=${escapeHtml(formatDuration(args.summary.durationMs))}</span>
838
+ </div>
839
+ <div class="meta">dir=${escapeHtml(args.summary.dir)}
840
+ summary=<a href="${escapeHtml(summaryHref)}">run.summary.json</a></div>
841
+ </header>
842
+ <main>
843
+ <aside>
844
+ <div class="controls">
845
+ <input id="search" class="input mono" placeholder="Search…" autocomplete="off" />
846
+ <button id="filterAll" class="badge chip" type="button" aria-pressed="true">all</button>
847
+ <button id="filterPass" class="badge chip pass" type="button" aria-pressed="false">pass</button>
848
+ <button id="filterFail" class="badge chip fail" type="button" aria-pressed="false">fail</button>
849
+ <span id="visibleCount" class="badge">visible=0</span>
850
+ </div>
851
+ <ol id="list" class="list"></ol>
852
+ </aside>
853
+ <section>
854
+ <div id="details">
855
+ <div class="muted">Select a test from the left.</div>
856
+ </div>
857
+ </section>
858
+ </main>
859
+ <script id="suiteData" type="application/json">${jsonForHtml(uiData)}<\/script>
860
+ <script>
861
+ (function () {
862
+ const dataEl = document.getElementById("suiteData");
863
+ const listEl = document.getElementById("list");
864
+ const detailsEl = document.getElementById("details");
865
+ const searchEl = document.getElementById("search");
866
+ const visibleCountEl = document.getElementById("visibleCount");
867
+ const filterAllEl = document.getElementById("filterAll");
868
+ const filterPassEl = document.getElementById("filterPass");
869
+ const filterFailEl = document.getElementById("filterFail");
870
+ if (!dataEl || !listEl || !detailsEl || !searchEl) return;
871
+
872
+ /** @type {{entries: any[]}} */
873
+ const raw = JSON.parse(dataEl.textContent || "{}");
874
+ const entries = Array.isArray(raw.entries) ? raw.entries : [];
875
+ let filter = "all";
876
+ let selectedId = null;
877
+ const dataLoaders = Object.create(null);
878
+
879
+ function setPressed(el, on) {
880
+ el.setAttribute("aria-pressed", on ? "true" : "false");
881
+ }
882
+
883
+ function applyFilter() {
884
+ const q = (searchEl.value || "").trim().toLowerCase();
885
+ const out = [];
886
+ for (const e of entries) {
887
+ if (!e) continue;
888
+ if (filter === "pass" && !e.ok) continue;
889
+ if (filter === "fail" && e.ok) continue;
890
+ if (q) {
891
+ const hay = (e.scriptName + " " + e.filePathRel).toLowerCase();
892
+ if (!hay.includes(q)) continue;
893
+ }
894
+ out.push(e);
895
+ }
896
+ renderList(out);
897
+ if (visibleCountEl) visibleCountEl.textContent = "visible=" + out.length;
898
+ }
899
+
900
+ function renderList(items) {
901
+ listEl.textContent = "";
902
+ for (const e of items) {
903
+ const li = document.createElement("li");
904
+ li.className = "item";
905
+ li.setAttribute("role", "option");
906
+ li.dataset.id = e.id;
907
+ li.setAttribute("aria-selected", e.id === selectedId ? "true" : "false");
908
+
909
+ const top = document.createElement("div");
910
+ top.className = "top";
911
+ const badge = document.createElement("span");
912
+ badge.className = "badge " + (e.ok ? "pass" : "fail");
913
+ badge.textContent = e.ok ? "PASS" : "FAIL";
914
+ const name = document.createElement("div");
915
+ name.className = "name";
916
+ name.textContent = e.scriptName;
917
+ top.appendChild(badge);
918
+ top.appendChild(name);
919
+
920
+ const sub = document.createElement("div");
921
+ sub.className = "sub";
922
+ const file = document.createElement("span");
923
+ file.textContent = e.filePathRel;
924
+ const dur = document.createElement("span");
925
+ dur.textContent = "dur=" + (e.durationMs < 1000 ? e.durationMs + "ms" : (e.durationMs / 1000).toFixed(2) + "s");
926
+ sub.appendChild(file);
927
+ sub.appendChild(dur);
928
+
929
+ li.appendChild(top);
930
+ li.appendChild(sub);
931
+ li.addEventListener("click", function () {
932
+ selectedId = e.id;
933
+ applyFilter();
934
+ renderDetails(e);
935
+ });
936
+ listEl.appendChild(li);
937
+ }
938
+ }
939
+
940
+ function linkHtml(href, label) {
941
+ if (!href) return "";
942
+ return '<a class="mono" href="' + href.replaceAll('"', "&quot;") + '">' + label + "</a>";
943
+ }
944
+
945
+ function escapeText(s) {
946
+ return (s || "")
947
+ .replaceAll("&", "&amp;")
948
+ .replaceAll("<", "&lt;")
949
+ .replaceAll(">", "&gt;");
950
+ }
951
+
952
+ function getTestData(e) {
953
+ const store = globalThis.__ptywright && globalThis.__ptywright.tests;
954
+ if (!store || !e || !e.dataKey) return null;
955
+ return store[e.dataKey] || null;
956
+ }
957
+
958
+ function ensureTestDataLoaded(e, cb) {
959
+ if (!e || !e.hrefs || !e.hrefs.data || !e.dataKey) return cb(null);
960
+ const existing = getTestData(e);
961
+ if (existing) return cb(existing);
962
+
963
+ if (dataLoaders[e.dataKey]) {
964
+ dataLoaders[e.dataKey].push(cb);
965
+ return;
966
+ }
967
+ dataLoaders[e.dataKey] = [cb];
968
+
969
+ const script = document.createElement("script");
970
+ script.src = e.hrefs.data;
971
+ script.async = true;
972
+ script.onload = function () {
973
+ const loaded = getTestData(e);
974
+ const cbs = dataLoaders[e.dataKey] || [];
975
+ delete dataLoaders[e.dataKey];
976
+ for (const fn of cbs) fn(loaded);
977
+ };
978
+ script.onerror = function () {
979
+ const cbs = dataLoaders[e.dataKey] || [];
980
+ delete dataLoaders[e.dataKey];
981
+ for (const fn of cbs) fn(null);
982
+ };
983
+ document.head.appendChild(script);
984
+ }
985
+
986
+ function renderDetails(e) {
987
+ const links = [
988
+ linkHtml(e.hrefs && e.hrefs.report, "report"),
989
+ linkHtml(e.hrefs && e.hrefs.play, "play"),
990
+ linkHtml(e.hrefs && e.hrefs.cast, "cast"),
991
+ linkHtml(e.hrefs && e.hrefs.last, "last"),
992
+ linkHtml(e.hrefs && e.hrefs.error, "error"),
993
+ ].filter(Boolean);
994
+
995
+ const data = getTestData(e);
996
+ const stepsHtml = (() => {
997
+ if (data && Array.isArray(data.steps)) {
998
+ const rows = data.steps
999
+ .map(function (s) {
1000
+ const badge = '<span class="badge ' + (s.ok ? "pass" : "fail") + '">' + (s.ok ? "PASS" : "FAIL") + "</span>";
1001
+ const dur = typeof s.durationMs === "number"
1002
+ ? (s.durationMs < 1000 ? s.durationMs + "ms" : (s.durationMs / 1000).toFixed(2) + "s")
1003
+ : "";
1004
+ const err = !s.ok && s.error ? '<div class="mono error" style="margin-top: 4px;">' + escapeText(s.error) + "</div>" : "";
1005
+ return '<div class="item" style="cursor: default;">' +
1006
+ '<div class="top">' + badge +
1007
+ '<div class="name mono" style="font-weight: 600;">' + escapeText(s.label || s.type || "") + "</div>" +
1008
+ "</div>" +
1009
+ '<div class="sub"><span>step=' + escapeText(String(s.index)) + "</span><span>dur=" + escapeText(dur) + "</span></div>" +
1010
+ err +
1011
+ "</div>";
1012
+ })
1013
+ .join("");
1014
+ return '<h3 style="margin: 16px 0 8px 0;">Steps</h3>' +
1015
+ '<div class="muted mono">count=' + escapeText(String(data.stepCount || data.steps.length)) + "</div>" +
1016
+ '<div style="margin-top: 10px; display: flex; flex-direction: column; gap: 8px;">' + rows + "</div>";
1017
+ }
1018
+
1019
+ if (e.hrefs && e.hrefs.data && e.dataKey) {
1020
+ return '<h3 style="margin: 16px 0 8px 0;">Steps</h3>' +
1021
+ '<div class="muted">Step details are available. Click to load.</div>' +
1022
+ '<div style="margin-top: 10px;">' +
1023
+ '<button id="loadSteps" class="badge chip" type="button">load steps</button>' +
1024
+ "</div>";
1025
+ }
1026
+
1027
+ return "";
1028
+ })();
1029
+
1030
+ detailsEl.innerHTML =
1031
+ '<div class="badges">' +
1032
+ '<span class="badge ' + (e.ok ? "pass" : "fail") + '">status=' + (e.ok ? "PASS" : "FAIL") + "</span>" +
1033
+ '<span class="badge">duration=' + (e.durationMs < 1000 ? e.durationMs + "ms" : (e.durationMs / 1000).toFixed(2) + "s") + "</span>" +
1034
+ "</div>" +
1035
+ '<h2 style="margin: 10px 0 6px 0;">' + escapeText(e.scriptName) + "</h2>" +
1036
+ '<div class="kv mono">' +
1037
+ '<div class="k">file</div><div class="v">' + escapeText(e.filePathRel) + "</div>" +
1038
+ '<div class="k">artifacts</div><div class="v">' +
1039
+ (e.artifactsDir
1040
+ ? escapeText(e.artifactsDir)
1041
+ : '<span class="muted">(none)</span>') +
1042
+ "</div>" +
1043
+ "</div>" +
1044
+ (links.length ? '<div class="links">' + links.join(" ") + "</div>" : "") +
1045
+ (!e.ok && e.error ? '<pre class="mono error" style="margin-top: 12px; white-space: pre-wrap;">' + escapeText(e.error) + "</pre>" : "") +
1046
+ stepsHtml;
1047
+
1048
+ const loadBtn = document.getElementById("loadSteps");
1049
+ if (loadBtn) {
1050
+ loadBtn.addEventListener("click", function () {
1051
+ ensureTestDataLoaded(e, function () {
1052
+ renderDetails(e);
1053
+ });
1054
+ });
1055
+ }
1056
+ }
1057
+
1058
+ filterAllEl.addEventListener("click", function () {
1059
+ filter = "all";
1060
+ setPressed(filterAllEl, true);
1061
+ setPressed(filterPassEl, false);
1062
+ setPressed(filterFailEl, false);
1063
+ applyFilter();
1064
+ });
1065
+ filterPassEl.addEventListener("click", function () {
1066
+ filter = "pass";
1067
+ setPressed(filterAllEl, false);
1068
+ setPressed(filterPassEl, true);
1069
+ setPressed(filterFailEl, false);
1070
+ applyFilter();
1071
+ });
1072
+ filterFailEl.addEventListener("click", function () {
1073
+ filter = "fail";
1074
+ setPressed(filterAllEl, false);
1075
+ setPressed(filterPassEl, false);
1076
+ setPressed(filterFailEl, true);
1077
+ applyFilter();
1078
+ });
1079
+ searchEl.addEventListener("input", applyFilter);
1080
+
1081
+ applyFilter();
1082
+ if (entries[0]) {
1083
+ selectedId = entries[0].id;
1084
+ renderDetails(entries[0]);
1085
+ applyFilter();
1086
+ }
1087
+ })();
1088
+ <\/script>
1089
+ </body>
1090
+ </html>`;
1091
+ }
1092
+ function jsonForHtml(data) {
1093
+ return JSON.stringify(data).replaceAll("<", "\\u003c");
1094
+ }
1095
+ function formatDuration(ms) {
1096
+ const safe = Math.max(0, Math.trunc(ms));
1097
+ if (safe < 1e3) return `${safe}ms`;
1098
+ return `${(safe / 1e3).toFixed(2)}s`;
1099
+ }
1100
+ function relativeHref(fromFile, toFile) {
1101
+ const normalized = normalizePath(relative(dirname(fromFile), toFile));
1102
+ return normalized.startsWith(".") ? normalized : `./${normalized}`;
1103
+ }
1104
+ function normalizePath(path) {
1105
+ return path.replace(/\\/g, "/");
1106
+ }
1107
+ function escapeHtml(text) {
1108
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
1109
+ }
1110
+ //#endregion
1111
+ //#region src/script/run_all.ts
1112
+ async function runAllScripts(options) {
1113
+ const dir = resolve(options?.dir?.trim() ? options.dir.trim() : "scripts");
1114
+ const artifactsRoot = options?.artifactsRoot?.trim() ? resolve(options.artifactsRoot.trim()) : null;
1115
+ const stepsPath = options?.stepsPath?.trim() ? options.stepsPath.trim() : void 0;
1116
+ const suiteDir = artifactsRoot ?? resolve(".tmp", "run-all");
1117
+ const filePaths = listScriptFiles(dir);
1118
+ const entries = [];
1119
+ const startedAt = Date.now();
1120
+ for (const filePath of filePaths) {
1121
+ const artifactsDir = join(suiteDir, "tests", safeArtifactsDirName(relative(dir, filePath)));
1122
+ const entryStartedAt = Date.now();
1123
+ const result = await runScriptPath(filePath, {
1124
+ artifactsDir,
1125
+ stepsPath,
1126
+ updateGoldens: options?.updateGoldens
1127
+ });
1128
+ const durationMs = Date.now() - entryStartedAt;
1129
+ entries.push({
1130
+ filePath,
1131
+ durationMs,
1132
+ result
1133
+ });
1134
+ }
1135
+ const durationMs = Date.now() - startedAt;
1136
+ const { reportPath, summaryPath } = writeSuiteReportArtifacts({
1137
+ dir,
1138
+ suiteDir,
1139
+ stepsPath,
1140
+ durationMs,
1141
+ entries
1142
+ });
1143
+ return {
1144
+ ok: entries.every((e) => e.result.ok),
1145
+ dir,
1146
+ suiteDir,
1147
+ durationMs,
1148
+ reportPath,
1149
+ summaryPath,
1150
+ entries
1151
+ };
1152
+ }
1153
+ function safeArtifactsDirName(relPath) {
1154
+ return relPath.replace(/[/\\]/g, "__");
1155
+ }
1156
+ function shouldIncludeJsonScript(filePath) {
1157
+ try {
1158
+ const raw = readFileSync(filePath, "utf8");
1159
+ const parsed = JSON.parse(raw);
1160
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
1161
+ const obj = parsed;
1162
+ if (!obj.launch || typeof obj.launch !== "object" || Array.isArray(obj.launch)) return false;
1163
+ const launch = obj.launch;
1164
+ if (typeof launch.command !== "string" || !launch.command.trim()) return false;
1165
+ return Array.isArray(obj.steps) && obj.steps.length > 0;
1166
+ } catch {
1167
+ return true;
1168
+ }
1169
+ }
1170
+ function listScriptFiles(dir) {
1171
+ const out = [];
1172
+ for (const entry of readdirSync(dir)) {
1173
+ const abs = join(dir, entry);
1174
+ if (statSync(abs).isDirectory()) {
1175
+ out.push(...listScriptFiles(abs));
1176
+ continue;
1177
+ }
1178
+ if (entry.endsWith(".json")) {
1179
+ if (shouldIncludeJsonScript(abs)) out.push(abs);
1180
+ continue;
1181
+ }
1182
+ if (!entry.endsWith(".ts")) continue;
1183
+ if (entry.endsWith(".d.ts")) continue;
1184
+ if (entry.endsWith("_steps.ts") || entry.endsWith(".steps.ts")) continue;
1185
+ out.push(abs);
1186
+ }
1187
+ return out.sort((a, b) => a.localeCompare(b));
1188
+ }
1189
+ function parseArgs(argv) {
1190
+ const out = { updateGoldens: false };
1191
+ for (let i = 0; i < argv.length; i += 1) {
1192
+ const arg = argv[i];
1193
+ const next = argv[i + 1];
1194
+ if (!out.dir && arg && !arg.startsWith("-")) {
1195
+ out.dir = arg;
1196
+ continue;
1197
+ }
1198
+ if (arg === "--dir" && next) {
1199
+ out.dir = next;
1200
+ i += 1;
1201
+ continue;
1202
+ }
1203
+ if (arg === "--artifacts-root" && next) {
1204
+ out.artifactsRoot = next;
1205
+ i += 1;
1206
+ continue;
1207
+ }
1208
+ if (arg === "--steps" && next) {
1209
+ out.stepsPath = next;
1210
+ i += 1;
1211
+ continue;
1212
+ }
1213
+ if (arg === "--update-goldens") {
1214
+ out.updateGoldens = true;
1215
+ continue;
1216
+ }
1217
+ throw new Error(`unknown arg: ${arg ?? ""}`);
1218
+ }
1219
+ return out;
1220
+ }
1221
+ if (import.meta.main) try {
1222
+ const args = parseArgs(process.argv.slice(2));
1223
+ const result = await runAllScripts({
1224
+ dir: args.dir,
1225
+ artifactsRoot: args.artifactsRoot,
1226
+ stepsPath: args.stepsPath,
1227
+ updateGoldens: args.updateGoldens
1228
+ });
1229
+ const failures = result.entries.filter((e) => !e.result.ok);
1230
+ if (failures.length === 0) {
1231
+ console.log(`ok count=${result.entries.length} dir=${result.dir}\nreport=${result.reportPath}\nsummary=${result.summaryPath}`);
1232
+ process.exitCode = 0;
1233
+ } else {
1234
+ console.error(`failed count=${failures.length}/${result.entries.length} dir=${result.dir}\nreport=${result.reportPath}\nsummary=${result.summaryPath}`);
1235
+ for (const f of failures) {
1236
+ if (f.result.ok) continue;
1237
+ console.error(`- ${f.filePath}: ${f.result.error}`);
1238
+ if (f.result.failureArtifacts) {
1239
+ console.error(` artifacts=${f.result.artifactsDir ?? ""}`);
1240
+ console.error(` last=${f.result.failureArtifacts.lastViewPath}`);
1241
+ console.error(` error=${f.result.failureArtifacts.errorPath}`);
1242
+ }
1243
+ }
1244
+ process.exitCode = 1;
1245
+ }
1246
+ } catch (error) {
1247
+ console.error(error.message);
1248
+ process.exitCode = 1;
1249
+ }
1250
+ //#endregion
1251
+ //#region src/generator/doc_parser.ts
1252
+ async function parseDocument(source) {
1253
+ const content = await fetchContent(source);
1254
+ switch (detectFormat(source, content)) {
1255
+ case "markdown": return parseMarkdown(content);
1256
+ case "json": return parseJson(content);
1257
+ case "yaml": return parseYaml(content);
1258
+ case "html": return parseHtml(content);
1259
+ default: return parsePlainText(content);
1260
+ }
1261
+ }
1262
+ async function fetchContent(source) {
1263
+ if (source.content) return source.content;
1264
+ if (source.type === "local" && source.path) {
1265
+ if (!existsSync(source.path)) throw new Error(`File not found: ${source.path}`);
1266
+ return readFileSync(source.path, "utf8");
1267
+ }
1268
+ if (source.type === "url" && source.url) {
1269
+ const response = await fetch(source.url);
1270
+ if (!response.ok) throw new Error(`Failed to fetch URL: ${source.url} (${response.status})`);
1271
+ return response.text();
1272
+ }
1273
+ throw new Error("Invalid document source: must provide path, url, or content");
1274
+ }
1275
+ function detectFormat(source, content) {
1276
+ if (source.path) {
1277
+ const ext = extname(source.path).toLowerCase();
1278
+ if (ext === ".md" || ext === ".markdown") return "markdown";
1279
+ if (ext === ".html" || ext === ".htm") return "html";
1280
+ if (ext === ".json") return "json";
1281
+ if (ext === ".yaml" || ext === ".yml") return "yaml";
1282
+ }
1283
+ const trimmed = content.trim();
1284
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
1285
+ if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) return "html";
1286
+ if (/^#\s/.test(trimmed) || /\n##\s/.test(content) || /```/.test(content)) return "markdown";
1287
+ return "text";
1288
+ }
1289
+ function parseMarkdown(content) {
1290
+ const lines = content.split("\n");
1291
+ const codeBlocks = [];
1292
+ const steps = [];
1293
+ let title;
1294
+ let description;
1295
+ let inCodeBlock = false;
1296
+ let currentLang = "";
1297
+ let currentCode = [];
1298
+ let codeBlockStart = 0;
1299
+ for (let i = 0; i < lines.length; i++) {
1300
+ const line = lines[i] ?? "";
1301
+ if (!title && /^#\s+(.+)$/.test(line)) {
1302
+ title = line.replace(/^#\s+/, "").trim();
1303
+ continue;
1304
+ }
1305
+ if (title && !description && !inCodeBlock && line.trim() && !/^[#-]/.test(line)) description = line.trim();
1306
+ if (line.startsWith("```")) {
1307
+ if (!inCodeBlock) {
1308
+ inCodeBlock = true;
1309
+ currentLang = line.slice(3).trim().toLowerCase();
1310
+ currentCode = [];
1311
+ codeBlockStart = i + 1;
1312
+ } else {
1313
+ inCodeBlock = false;
1314
+ if (currentCode.length > 0) codeBlocks.push({
1315
+ language: currentLang || "text",
1316
+ code: currentCode.join("\n"),
1317
+ lineNumber: codeBlockStart
1318
+ });
1319
+ }
1320
+ continue;
1321
+ }
1322
+ if (inCodeBlock) {
1323
+ currentCode.push(line);
1324
+ continue;
1325
+ }
1326
+ const stepMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
1327
+ if (stepMatch && stepMatch[2]) steps.push(stepMatch[2].trim());
1328
+ const bulletMatch = line.match(/^\s*[-*]\s+(.+)$/);
1329
+ if (bulletMatch && bulletMatch[1]) {
1330
+ const text = bulletMatch[1].trim();
1331
+ if (isLikelyStep(text)) steps.push(text);
1332
+ }
1333
+ }
1334
+ return {
1335
+ title,
1336
+ description,
1337
+ codeBlocks,
1338
+ steps,
1339
+ rawContent: content,
1340
+ format: "markdown"
1341
+ };
1342
+ }
1343
+ function parseJson(content) {
1344
+ const parsed = JSON.parse(content);
1345
+ const codeBlocks = [];
1346
+ const steps = [];
1347
+ if (typeof parsed === "object" && parsed !== null) {
1348
+ const obj = parsed;
1349
+ if ("launch" in obj && "steps" in obj) return {
1350
+ title: obj.name ?? "Imported Script",
1351
+ description: "Parsed from JSON script",
1352
+ codeBlocks: [],
1353
+ steps: [],
1354
+ rawContent: content,
1355
+ format: "json"
1356
+ };
1357
+ if (Array.isArray(obj.commands)) {
1358
+ for (const cmd of obj.commands) if (typeof cmd === "string") steps.push(cmd);
1359
+ else if (typeof cmd === "object" && cmd && "command" in cmd) steps.push(String(cmd.command));
1360
+ }
1361
+ if (Array.isArray(obj.steps)) {
1362
+ for (const step of obj.steps) if (typeof step === "string") steps.push(step);
1363
+ else if (typeof step === "object" && step) {
1364
+ const s = step;
1365
+ if (typeof s.description === "string") steps.push(s.description);
1366
+ else if (typeof s.command === "string") steps.push(s.command);
1367
+ }
1368
+ }
1369
+ }
1370
+ return {
1371
+ codeBlocks,
1372
+ steps,
1373
+ rawContent: content,
1374
+ format: "json"
1375
+ };
1376
+ }
1377
+ function parseYaml(content) {
1378
+ const steps = [];
1379
+ const lines = content.split("\n");
1380
+ for (const line of lines) {
1381
+ const match = line.match(/^\s*-\s+(.+)$/);
1382
+ if (match && match[1]) {
1383
+ const value = match[1].trim();
1384
+ if (!value.startsWith("{") && !value.includes(":")) steps.push(value);
1385
+ }
1386
+ }
1387
+ return {
1388
+ codeBlocks: [],
1389
+ steps,
1390
+ rawContent: content,
1391
+ format: "yaml"
1392
+ };
1393
+ }
1394
+ function parseHtml(content) {
1395
+ const codeBlocks = [];
1396
+ const steps = [];
1397
+ let title;
1398
+ const titleMatch = content.match(/<title>([^<]+)<\/title>/i);
1399
+ if (titleMatch && titleMatch[1]) title = titleMatch[1].trim();
1400
+ const codeRegex = /<code[^>]*(?:class="[^"]*language-(\w+)[^"]*")?[^>]*>([\s\S]*?)<\/code>/gi;
1401
+ let match;
1402
+ while ((match = codeRegex.exec(content)) !== null) {
1403
+ const lang = match[1] ?? "text";
1404
+ const code = decodeHtmlEntities(match[2] ?? "");
1405
+ if (code.trim()) codeBlocks.push({
1406
+ language: lang,
1407
+ code: code.trim(),
1408
+ lineNumber: 0
1409
+ });
1410
+ }
1411
+ const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
1412
+ while ((match = liRegex.exec(content)) !== null) {
1413
+ const text = stripHtmlTags(match[1] ?? "").trim();
1414
+ if (text && isLikelyStep(text)) steps.push(text);
1415
+ }
1416
+ return {
1417
+ title,
1418
+ codeBlocks,
1419
+ steps,
1420
+ rawContent: content,
1421
+ format: "html"
1422
+ };
1423
+ }
1424
+ function parsePlainText(content) {
1425
+ const lines = content.split("\n");
1426
+ const steps = [];
1427
+ for (const line of lines) {
1428
+ const trimmed = line.trim();
1429
+ if (!trimmed) continue;
1430
+ const numberedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
1431
+ if (numberedMatch && numberedMatch[1]) {
1432
+ steps.push(numberedMatch[1].trim());
1433
+ continue;
1434
+ }
1435
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
1436
+ if (bulletMatch && bulletMatch[1]) {
1437
+ steps.push(bulletMatch[1].trim());
1438
+ continue;
1439
+ }
1440
+ if (isLikelyStep(trimmed)) steps.push(trimmed);
1441
+ }
1442
+ return {
1443
+ codeBlocks: [],
1444
+ steps,
1445
+ rawContent: content,
1446
+ format: "text"
1447
+ };
1448
+ }
1449
+ function isLikelyStep(text) {
1450
+ return [
1451
+ /^(run|execute|type|enter|press|click|open|start|stop|wait|check|verify|assert)/i,
1452
+ /^(输入|执行|运行|点击|打开|启动|停止|等待|检查|验证)/,
1453
+ /\$\s+\w+/,
1454
+ /^>\s+\w+/
1455
+ ].some((pattern) => pattern.test(text));
1456
+ }
1457
+ function decodeHtmlEntities(text) {
1458
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&nbsp;/g, " ");
1459
+ }
1460
+ function stripHtmlTags(html) {
1461
+ return html.replace(/<[^>]+>/g, "");
1462
+ }
1463
+ //#endregion
1464
+ //#region src/generator/step_extractor.ts
1465
+ function extractSteps(doc) {
1466
+ const warnings = [];
1467
+ const steps = [];
1468
+ let launch;
1469
+ for (const block of doc.codeBlocks) if (isShellLanguage(block.language)) {
1470
+ const extracted = extractFromShellBlock(block);
1471
+ if (!launch && extracted.launch) launch = extracted.launch;
1472
+ steps.push(...extracted.steps);
1473
+ }
1474
+ if (steps.length === 0 && doc.steps.length > 0) for (const textStep of doc.steps) {
1475
+ const extracted = extractFromTextStep(textStep);
1476
+ if (extracted) steps.push(extracted);
1477
+ }
1478
+ if (steps.length === 0) {
1479
+ for (const block of doc.codeBlocks) if (!isShellLanguage(block.language)) {
1480
+ const extracted = extractFromGenericCodeBlock(block);
1481
+ steps.push(...extracted);
1482
+ }
1483
+ }
1484
+ const stepsWithWaits = insertDefaultWaits(steps);
1485
+ if (!launch && stepsWithWaits.length > 0) warnings.push("No launch command detected. You may need to specify targetCommand.");
1486
+ if (stepsWithWaits.length === 0) warnings.push("No test steps could be extracted from the document.");
1487
+ return {
1488
+ launch,
1489
+ steps: stepsWithWaits,
1490
+ warnings
1491
+ };
1492
+ }
1493
+ function isShellLanguage(lang) {
1494
+ return [
1495
+ "bash",
1496
+ "sh",
1497
+ "shell",
1498
+ "zsh",
1499
+ "fish",
1500
+ "console",
1501
+ "terminal",
1502
+ "cmd",
1503
+ "powershell"
1504
+ ].includes(lang.toLowerCase());
1505
+ }
1506
+ function extractFromShellBlock(block) {
1507
+ const lines = block.code.split("\n").filter((l) => l.trim());
1508
+ const steps = [];
1509
+ let launch;
1510
+ for (const line of lines) {
1511
+ const trimmed = line.trim();
1512
+ if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
1513
+ const command = trimmed.replace(/^\$\s+/, "").replace(/^>\s+/, "").replace(/^%\s+/, "");
1514
+ if (!command) continue;
1515
+ if (!launch && isLaunchCandidate(command)) {
1516
+ launch = parseLaunchCommand(command);
1517
+ continue;
1518
+ }
1519
+ const step = parseCommandAsStep(command);
1520
+ if (step) steps.push(step);
1521
+ }
1522
+ return {
1523
+ launch,
1524
+ steps
1525
+ };
1526
+ }
1527
+ function extractFromTextStep(text) {
1528
+ const normalized = text.toLowerCase();
1529
+ const typeMatch = text.match(/^(?:type|enter|input)\s+["`']?(.+?)["`']?$/i);
1530
+ if (typeMatch && typeMatch[1]) return {
1531
+ type: "sendText",
1532
+ params: {
1533
+ text: typeMatch[1],
1534
+ enter: true
1535
+ },
1536
+ source: "text_step",
1537
+ confidence: "medium",
1538
+ rawText: text
1539
+ };
1540
+ const pressMatch = text.match(/^press\s+(.+)$/i);
1541
+ if (pressMatch && pressMatch[1]) return {
1542
+ type: "pressKey",
1543
+ params: { key: normalizeKeyName(pressMatch[1]) },
1544
+ source: "text_step",
1545
+ confidence: "medium",
1546
+ rawText: text
1547
+ };
1548
+ const waitMatch = text.match(/^wait\s+(?:for\s+)?["`']?(.+?)["`']?$/i);
1549
+ if (waitMatch && waitMatch[1]) return {
1550
+ type: "waitForText",
1551
+ params: {
1552
+ text: waitMatch[1],
1553
+ timeoutMs: 1e4
1554
+ },
1555
+ source: "text_step",
1556
+ confidence: "medium",
1557
+ rawText: text
1558
+ };
1559
+ const assertMatch = text.match(/^(?:check|verify|assert|expect)\s+(?:that\s+)?["`']?(.+?)["`']?$/i);
1560
+ if (assertMatch && assertMatch[1]) return {
1561
+ type: "assert",
1562
+ params: {
1563
+ text: assertMatch[1],
1564
+ description: text
1565
+ },
1566
+ source: "text_step",
1567
+ confidence: "medium",
1568
+ rawText: text
1569
+ };
1570
+ const runMatch = text.match(/^(?:run|execute)\s+(.+)$/i);
1571
+ if (runMatch && runMatch[1]) return {
1572
+ type: "sendText",
1573
+ params: {
1574
+ text: runMatch[1],
1575
+ enter: true
1576
+ },
1577
+ source: "text_step",
1578
+ confidence: "low",
1579
+ rawText: text
1580
+ };
1581
+ if (/^输入\s+/.test(text)) return {
1582
+ type: "sendText",
1583
+ params: {
1584
+ text: text.replace(/^输入\s+/, "").trim(),
1585
+ enter: true
1586
+ },
1587
+ source: "text_step",
1588
+ confidence: "medium",
1589
+ rawText: text
1590
+ };
1591
+ if (/^等待\s+/.test(text)) return {
1592
+ type: "waitForText",
1593
+ params: {
1594
+ text: text.replace(/^等待\s+/, "").trim(),
1595
+ timeoutMs: 1e4
1596
+ },
1597
+ source: "text_step",
1598
+ confidence: "medium",
1599
+ rawText: text
1600
+ };
1601
+ if (/^[a-z_][a-z0-9_-]*\s/i.test(normalized) || text.startsWith("./")) return {
1602
+ type: "sendText",
1603
+ params: {
1604
+ text: text.trim(),
1605
+ enter: true
1606
+ },
1607
+ source: "text_step",
1608
+ confidence: "low",
1609
+ rawText: text
1610
+ };
1611
+ return null;
1612
+ }
1613
+ function extractFromGenericCodeBlock(block) {
1614
+ const steps = [];
1615
+ const lines = block.code.split("\n").filter((l) => l.trim());
1616
+ for (const line of lines) if (/^[a-z_][a-z0-9_-]*(\s|$)/i.test(line.trim())) steps.push({
1617
+ type: "sendText",
1618
+ params: {
1619
+ text: line.trim(),
1620
+ enter: true
1621
+ },
1622
+ source: "code_block",
1623
+ confidence: "low",
1624
+ rawText: line
1625
+ });
1626
+ return steps;
1627
+ }
1628
+ function isLaunchCandidate(command) {
1629
+ return [
1630
+ /^(node|bun|deno|python|python3|ruby|perl)\s+/i,
1631
+ /^(npm|yarn|pnpm|bun)\s+(run|start|test)/i,
1632
+ /^(cargo|go|rust)\s+run/i,
1633
+ /^\.\//,
1634
+ /^[a-z_][a-z0-9_-]*$/i
1635
+ ].some((p) => p.test(command));
1636
+ }
1637
+ function parseLaunchCommand(command) {
1638
+ const [cmd, ...args] = parseCommandLine(command);
1639
+ return {
1640
+ command: cmd ?? command,
1641
+ args: args.length > 0 ? args : void 0,
1642
+ confidence: "high"
1643
+ };
1644
+ }
1645
+ function parseCommandLine(command) {
1646
+ const parts = [];
1647
+ let current = "";
1648
+ let inQuote = null;
1649
+ let escape = false;
1650
+ for (const char of command) {
1651
+ if (escape) {
1652
+ current += char;
1653
+ escape = false;
1654
+ continue;
1655
+ }
1656
+ if (char === "\\") {
1657
+ escape = true;
1658
+ continue;
1659
+ }
1660
+ if ((char === "\"" || char === "'") && !inQuote) {
1661
+ inQuote = char;
1662
+ continue;
1663
+ }
1664
+ if (char === inQuote) {
1665
+ inQuote = null;
1666
+ continue;
1667
+ }
1668
+ if (char === " " && !inQuote) {
1669
+ if (current) {
1670
+ parts.push(current);
1671
+ current = "";
1672
+ }
1673
+ continue;
1674
+ }
1675
+ current += char;
1676
+ }
1677
+ if (current) parts.push(current);
1678
+ return parts;
1679
+ }
1680
+ function parseCommandAsStep(command) {
1681
+ if (/^(error|warning|info|debug|note):/i.test(command)) return null;
1682
+ return {
1683
+ type: "sendText",
1684
+ params: {
1685
+ text: command,
1686
+ enter: true
1687
+ },
1688
+ source: "code_block",
1689
+ confidence: "high",
1690
+ rawText: command
1691
+ };
1692
+ }
1693
+ function normalizeKeyName(key) {
1694
+ return {
1695
+ enter: "Enter",
1696
+ return: "Enter",
1697
+ tab: "Tab",
1698
+ escape: "Escape",
1699
+ esc: "Escape",
1700
+ space: "Space",
1701
+ backspace: "Backspace",
1702
+ delete: "Delete",
1703
+ up: "ArrowUp",
1704
+ down: "ArrowDown",
1705
+ left: "ArrowLeft",
1706
+ right: "ArrowRight",
1707
+ home: "Home",
1708
+ end: "End",
1709
+ pageup: "PageUp",
1710
+ pagedown: "PageDown",
1711
+ "ctrl+c": "Ctrl+C",
1712
+ "ctrl+d": "Ctrl+D",
1713
+ "ctrl+z": "Ctrl+Z"
1714
+ }[key.toLowerCase().trim()] ?? key;
1715
+ }
1716
+ function insertDefaultWaits(steps) {
1717
+ const result = [];
1718
+ for (let i = 0; i < steps.length; i++) {
1719
+ const step = steps[i];
1720
+ if (!step) continue;
1721
+ result.push(step);
1722
+ const nextStep = steps[i + 1];
1723
+ const isInputStep = step.type === "sendText" || step.type === "pressKey";
1724
+ const nextIsWait = nextStep?.type === "waitForText" || nextStep?.type === "waitForStableScreen" || nextStep?.type === "sleep";
1725
+ if (isInputStep && !nextIsWait && i < steps.length - 1) result.push({
1726
+ type: "waitForStableScreen",
1727
+ params: {
1728
+ timeoutMs: 5e3,
1729
+ quietMs: 300
1730
+ },
1731
+ source: "inferred",
1732
+ confidence: "low"
1733
+ });
1734
+ }
1735
+ return result;
1736
+ }
1737
+ //#endregion
1738
+ //#region src/generator/script_generator.ts
1739
+ function generateScript(steps, options) {
1740
+ const script = buildScript(steps, options);
1741
+ mkdirSync(options.outputDir, { recursive: true });
1742
+ let jsonPath;
1743
+ let tsPath;
1744
+ if (options.format === "json" || options.format === "both") {
1745
+ jsonPath = join(options.outputDir, `${options.name}.json`);
1746
+ const jsonContent = generateJsonScript(script, { schemaPath: resolveJsonSchemaPath(options.outputDir) });
1747
+ writeFileSync(jsonPath, jsonContent, "utf8");
1748
+ }
1749
+ if (options.format === "ts" || options.format === "both") {
1750
+ tsPath = join(options.outputDir, `${options.name}.ts`);
1751
+ const tsContent = generateTypeScriptScript(script);
1752
+ writeFileSync(tsPath, tsContent, "utf8");
1753
+ }
1754
+ return {
1755
+ name: options.name,
1756
+ jsonPath,
1757
+ tsPath,
1758
+ script,
1759
+ stepCount: script.steps.length
1760
+ };
1761
+ }
1762
+ function buildScript(steps, options) {
1763
+ const launch = resolveLaunch(options);
1764
+ const scriptSteps = steps.map(convertToScriptStep);
1765
+ if (!scriptSteps.some((s) => s.type === "snapshot" || s.type === "expect" || s.type === "expectGolden") && scriptSteps.length > 0) scriptSteps.push({
1766
+ type: "snapshot",
1767
+ kind: "view",
1768
+ scope: "visible",
1769
+ trimRight: true,
1770
+ trimBottom: true
1771
+ });
1772
+ return {
1773
+ name: options.name,
1774
+ launch,
1775
+ trace: options.trace ?? {
1776
+ saveCast: true,
1777
+ saveReport: true
1778
+ },
1779
+ steps: scriptSteps
1780
+ };
1781
+ }
1782
+ function resolveLaunch(options) {
1783
+ if (options.targetCommand) return {
1784
+ command: options.targetCommand,
1785
+ args: options.targetArgs,
1786
+ cols: options.cols ?? 80,
1787
+ rows: options.rows ?? 24,
1788
+ env: options.env
1789
+ };
1790
+ if (options.launch) return {
1791
+ command: options.launch.command,
1792
+ args: options.launch.args,
1793
+ cwd: options.launch.cwd,
1794
+ cols: options.cols ?? 80,
1795
+ rows: options.rows ?? 24,
1796
+ env: options.env ?? options.launch.env
1797
+ };
1798
+ return {
1799
+ command: "bash",
1800
+ cols: options.cols ?? 80,
1801
+ rows: options.rows ?? 24
1802
+ };
1803
+ }
1804
+ function convertToScriptStep(extracted) {
1805
+ const { type, params } = extracted;
1806
+ switch (type) {
1807
+ case "sendText": return {
1808
+ type: "sendText",
1809
+ text: typeof params.text === "string" ? params.text : "",
1810
+ enter: params.enter
1811
+ };
1812
+ case "pressKey": return {
1813
+ type: "pressKey",
1814
+ key: typeof params.key === "string" ? params.key : "Enter"
1815
+ };
1816
+ case "waitForText": return {
1817
+ type: "waitForText",
1818
+ text: params.text,
1819
+ regex: params.regex,
1820
+ scope: params.scope ?? "visible",
1821
+ timeoutMs: params.timeoutMs ?? 1e4
1822
+ };
1823
+ case "waitForStableScreen": return {
1824
+ type: "waitForStableScreen",
1825
+ timeoutMs: params.timeoutMs ?? 5e3,
1826
+ quietMs: params.quietMs ?? 300
1827
+ };
1828
+ case "assert": return {
1829
+ type: "assert",
1830
+ text: params.text,
1831
+ regex: params.regex,
1832
+ description: params.description
1833
+ };
1834
+ case "sleep": return {
1835
+ type: "sleep",
1836
+ ms: params.ms ?? 1e3
1837
+ };
1838
+ case "snapshot": return {
1839
+ type: "snapshot",
1840
+ kind: params.kind ?? "view",
1841
+ scope: params.scope ?? "visible",
1842
+ trimRight: true,
1843
+ trimBottom: true
1844
+ };
1845
+ default: return {
1846
+ type: "sendText",
1847
+ text: typeof params.text === "string" ? params.text : extracted.rawText ?? "",
1848
+ enter: true
1849
+ };
1850
+ }
1851
+ }
1852
+ function generateJsonScript(script, options) {
1853
+ const output = {
1854
+ $schema: options?.schemaPath ?? "../schemas/ptywright-script.schema.json",
1855
+ ...script
1856
+ };
1857
+ return JSON.stringify(output, null, 2) + "\n";
1858
+ }
1859
+ function generateTypeScriptScript(script) {
1860
+ return `export default ${JSON.stringify(script, null, 2)};\n`;
1861
+ }
1862
+ function resolveJsonSchemaPath(outputDir) {
1863
+ let schemaPath = relative(resolve(process.cwd(), outputDir), resolve(process.cwd(), "schemas", "ptywright-script.schema.json"));
1864
+ if (!schemaPath.startsWith(".")) schemaPath = `./${schemaPath}`;
1865
+ return schemaPath.replaceAll("\\", "/");
1866
+ }
1867
+ //#endregion
1868
+ //#region src/generator/generate.ts
1869
+ async function generateTestFromDoc(options) {
1870
+ const warnings = [];
1871
+ try {
1872
+ const parsed = await parseDocument(resolveDocumentSource(options.source, options.sourceType));
1873
+ const extraction = extractSteps(parsed);
1874
+ warnings.push(...extraction.warnings);
1875
+ if (extraction.steps.length === 0) return {
1876
+ ok: false,
1877
+ name: options.name ?? "unknown",
1878
+ stepCount: 0,
1879
+ warnings,
1880
+ error: "No test steps could be extracted from the document",
1881
+ parsed: {
1882
+ title: parsed.title,
1883
+ format: parsed.format,
1884
+ codeBlockCount: parsed.codeBlocks.length,
1885
+ textStepCount: parsed.steps.length
1886
+ }
1887
+ };
1888
+ const scriptName = resolveScriptName(options.name, options.source, parsed);
1889
+ const outputDir = options.outputDir ?? resolve(".tmp", "generated");
1890
+ const generated = generateScript(extraction.steps, {
1891
+ name: scriptName,
1892
+ launch: extraction.launch,
1893
+ targetCommand: options.targetCommand,
1894
+ targetArgs: options.targetArgs,
1895
+ outputDir,
1896
+ format: options.outputFormat ?? "both",
1897
+ cols: options.cols,
1898
+ rows: options.rows,
1899
+ env: options.env,
1900
+ trace: options.trace
1901
+ });
1902
+ return {
1903
+ ok: true,
1904
+ name: generated.name,
1905
+ jsonPath: generated.jsonPath,
1906
+ tsPath: generated.tsPath,
1907
+ stepCount: generated.stepCount,
1908
+ warnings,
1909
+ parsed: {
1910
+ title: parsed.title,
1911
+ format: parsed.format,
1912
+ codeBlockCount: parsed.codeBlocks.length,
1913
+ textStepCount: parsed.steps.length
1914
+ }
1915
+ };
1916
+ } catch (error) {
1917
+ return {
1918
+ ok: false,
1919
+ name: options.name ?? "unknown",
1920
+ stepCount: 0,
1921
+ warnings,
1922
+ error: error instanceof Error ? error.message : String(error)
1923
+ };
1924
+ }
1925
+ }
1926
+ function resolveDocumentSource(source, sourceType) {
1927
+ if ((!sourceType || sourceType === "auto" ? detectSourceType(source) : sourceType) === "url") return {
1928
+ type: "url",
1929
+ url: source
1930
+ };
1931
+ return {
1932
+ type: "local",
1933
+ path: resolve(source)
1934
+ };
1935
+ }
1936
+ function detectSourceType(source) {
1937
+ if (source.startsWith("http://") || source.startsWith("https://")) return "url";
1938
+ return "local";
1939
+ }
1940
+ function resolveScriptName(explicitName, source, parsed) {
1941
+ if (explicitName) return sanitizeName(explicitName);
1942
+ if (parsed.title) return sanitizeName(parsed.title);
1943
+ return sanitizeName(basename(source, extname(source)));
1944
+ }
1945
+ function sanitizeName(name) {
1946
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 64) || "generated_test";
1947
+ }
1948
+ //#endregion
1949
+ //#region src/mcp/script_recording.ts
1950
+ var ScriptRecordingManager = class {
1951
+ active = null;
1952
+ start(args) {
1953
+ if (this.active) throw new Error(`recording already active: ${this.active.id}`);
1954
+ const name = args.name.trim();
1955
+ if (!name) throw new Error("name is required");
1956
+ const outPath = (args.outPath?.trim() ? args.outPath.trim() : `scripts/${name}.json`).trim();
1957
+ const goldenDir = (args.goldenDir?.trim() ? args.goldenDir.trim() : `tests/golden/scripts/${name}`).trim();
1958
+ this.active = {
1959
+ id: crypto.randomUUID(),
1960
+ name,
1961
+ outPath,
1962
+ goldenDir,
1963
+ overwrite: args.overwrite ?? false,
1964
+ checkpoint: {
1965
+ scope: args.checkpoint?.scope ?? "visible",
1966
+ trimRight: args.checkpoint?.trimRight ?? true,
1967
+ trimBottom: args.checkpoint?.trimBottom ?? true,
1968
+ mask: args.checkpoint?.mask
1969
+ },
1970
+ launch: null,
1971
+ sessionId: null,
1972
+ steps: [],
1973
+ checkpointIndex: 0,
1974
+ goldenWrites: []
1975
+ };
1976
+ return this.status();
1977
+ }
1978
+ stop(args) {
1979
+ if (!this.active) throw new Error("no active recording");
1980
+ if (args.recordingId !== this.active.id) throw new Error(`recording not found: ${args.recordingId}`);
1981
+ const writeFiles = args.writeFiles ?? true;
1982
+ const recording = this.active;
1983
+ this.active = null;
1984
+ if (!recording.launch) throw new Error("recording has no launch_session");
1985
+ const schemaAbs = resolve("schemas/ptywright-script.schema.json");
1986
+ const built = {
1987
+ $schema: toPosixPath(relative(dirname(resolve(recording.outPath)), schemaAbs)),
1988
+ name: recording.name,
1989
+ launch: recording.launch,
1990
+ steps: recording.steps
1991
+ };
1992
+ const script = {
1993
+ ...scriptSchema.parse(built),
1994
+ $schema: built.$schema
1995
+ };
1996
+ const goldenPaths = recording.goldenWrites.map((w) => w.path);
1997
+ if (writeFiles) {
1998
+ writeOrThrow(recording.outPath, `${JSON.stringify(script, null, 2)}\n`, recording.overwrite);
1999
+ for (const w of recording.goldenWrites) writeOrThrow(w.path, `${w.text}\n`, recording.overwrite);
2000
+ }
2001
+ return {
2002
+ scriptPath: writeFiles ? recording.outPath : void 0,
2003
+ goldenPaths,
2004
+ script
2005
+ };
2006
+ }
2007
+ status() {
2008
+ if (!this.active) throw new Error("no active recording");
2009
+ return {
2010
+ recordingId: this.active.id,
2011
+ name: this.active.name,
2012
+ outPath: this.active.outPath,
2013
+ goldenDir: this.active.goldenDir,
2014
+ hasLaunch: this.active.launch !== null,
2015
+ stepCount: this.active.steps.length,
2016
+ checkpointCount: this.active.checkpointIndex
2017
+ };
2018
+ }
2019
+ recordLaunch(args, sessionId) {
2020
+ const rec = this.active;
2021
+ if (!rec) return;
2022
+ if (rec.launch) return;
2023
+ rec.launch = args;
2024
+ rec.sessionId = sessionId;
2025
+ }
2026
+ recordStep(step) {
2027
+ const rec = this.active;
2028
+ if (!rec) return;
2029
+ rec.steps.push(step);
2030
+ }
2031
+ async recordCheckpoint(args) {
2032
+ const rec = this.active;
2033
+ if (!rec) return;
2034
+ if (!rec.sessionId || rec.sessionId !== args.session.id) return;
2035
+ const safe = sanitizeLabel((args.label ?? "").trim() || `checkpoint_${rec.checkpointIndex + 1}`);
2036
+ rec.checkpointIndex += 1;
2037
+ const snapshot = await args.session.snapshotText({
2038
+ scope: rec.checkpoint.scope,
2039
+ trimRight: rec.checkpoint.trimRight,
2040
+ trimBottom: rec.checkpoint.trimBottom,
2041
+ captureFrame: true,
2042
+ mask: rec.checkpoint.mask
2043
+ });
2044
+ const goldenPath = toPosixPath(resolvePathLike(joinPosix(rec.goldenDir, `${safe}.txt`), false));
2045
+ rec.goldenWrites.push({
2046
+ path: goldenPath,
2047
+ text: snapshot.text
2048
+ });
2049
+ rec.steps.push({
2050
+ type: "snapshot",
2051
+ kind: "text",
2052
+ scope: rec.checkpoint.scope,
2053
+ trimRight: rec.checkpoint.trimRight,
2054
+ trimBottom: rec.checkpoint.trimBottom,
2055
+ mask: rec.checkpoint.mask
2056
+ });
2057
+ rec.steps.push({
2058
+ type: "expectGolden",
2059
+ path: goldenPath
2060
+ });
2061
+ }
2062
+ };
2063
+ function writeOrThrow(path, text, overwrite) {
2064
+ const abs = resolvePathLike(path, true);
2065
+ if (!overwrite && existsSync(abs)) throw new Error(`refusing to overwrite: ${path}`);
2066
+ mkdirSync(dirname(abs), { recursive: true });
2067
+ writeFileSync(abs, text, "utf8");
2068
+ }
2069
+ function resolvePathLike(path, absolute) {
2070
+ if (!absolute) return toPosixPath(path);
2071
+ return resolve(process.cwd(), path);
2072
+ }
2073
+ function sanitizeLabel(label) {
2074
+ return label.replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "checkpoint";
2075
+ }
2076
+ function toPosixPath(path) {
2077
+ return path.replace(/\\/g, "/");
2078
+ }
2079
+ function joinPosix(a, b) {
2080
+ return `${a.replace(/\\/g, "/").replace(/\/+$/g, "")}/${b.replace(/\\/g, "/").replace(/^\/+/g, "")}`;
2081
+ }
2082
+ //#endregion
2083
+ //#region package.json
2084
+ var version = "0.3.0";
2085
+ //#endregion
2086
+ //#region src/mcp/server.ts
2087
+ const textMaskRuleSchema = z.object({
2088
+ regex: z.string().min(1),
2089
+ flags: z.string().optional(),
2090
+ replacement: z.string().optional(),
2091
+ preserveLength: z.boolean().optional()
2092
+ });
2093
+ function createPtywrightServer(options) {
2094
+ const sessions = options?.sessionManager ?? new SessionManager();
2095
+ const recordings = new ScriptRecordingManager();
2096
+ const server = new McpServer({
2097
+ name: "ptywright",
2098
+ version
2099
+ });
2100
+ const caps = resolveCapabilities(options?.capabilities, process.env.PTYWRIGHT_CAPS);
2101
+ const selectedSessionByTransport = /* @__PURE__ */ new Map();
2102
+ function transportKey(extra) {
2103
+ return extra.sessionId ?? "default";
2104
+ }
2105
+ function getSelectedSessionId(extra) {
2106
+ return selectedSessionByTransport.get(transportKey(extra));
2107
+ }
2108
+ function setSelectedSessionId(extra, sessionId) {
2109
+ selectedSessionByTransport.set(transportKey(extra), sessionId);
2110
+ }
2111
+ function clearSelectedSessionId(extra) {
2112
+ selectedSessionByTransport.delete(transportKey(extra));
2113
+ }
2114
+ function isEnabled(category) {
2115
+ return caps.all || caps.enabled.has(category);
2116
+ }
2117
+ function tool(category, name, description, schema, annotations, handler) {
2118
+ if (!isEnabled(category)) return;
2119
+ const { title, ...rest } = annotations ?? {};
2120
+ const cleanedAnnotations = Object.keys(rest).length ? rest : void 0;
2121
+ server.registerTool(name, {
2122
+ title,
2123
+ description,
2124
+ inputSchema: schema,
2125
+ annotations: cleanedAnnotations,
2126
+ _meta: { category }
2127
+ }, handler);
2128
+ }
2129
+ tool("core", "select_session", "Select the default session for subsequent tool calls (so other tools can omit sessionId).", { sessionId: z.string().min(1) }, { title: "Select Session" }, async (args, extra) => {
2130
+ if (!sessions.getSession(args.sessionId)) return toolError(`session not found: ${args.sessionId}`);
2131
+ setSelectedSessionId(extra, args.sessionId);
2132
+ return {
2133
+ content: [{
2134
+ type: "text",
2135
+ text: `selected ${args.sessionId}`
2136
+ }],
2137
+ structuredContent: { sessionId: args.sessionId }
2138
+ };
2139
+ });
2140
+ tool("core", "launch_session", "Start here! Launch a CLI/TUI command to begin testing (e.g., 'vim', 'top', 'npm start'). Returns a sessionId required for other tools.", {
2141
+ command: z.string().min(1),
2142
+ args: z.array(z.string()).optional(),
2143
+ cwd: z.string().optional(),
2144
+ env: z.record(z.string()).optional(),
2145
+ cols: z.number().int().optional(),
2146
+ rows: z.number().int().optional(),
2147
+ name: z.string().optional()
2148
+ }, {
2149
+ title: "Launch Session",
2150
+ openWorldHint: true
2151
+ }, async (args, extra) => {
2152
+ const session = sessions.launchSession(args);
2153
+ setSelectedSessionId(extra, session.id);
2154
+ recordings.recordLaunch({
2155
+ command: args.command,
2156
+ args: args.args,
2157
+ cwd: args.cwd,
2158
+ env: args.env,
2159
+ cols: args.cols,
2160
+ rows: args.rows,
2161
+ name: args.name
2162
+ }, session.id);
2163
+ return {
2164
+ content: [{
2165
+ type: "text",
2166
+ text: `launched ${session.id}`
2167
+ }],
2168
+ structuredContent: { sessionId: session.id }
2169
+ };
2170
+ });
2171
+ tool("core", "send_text", "Send text input to a session (optionally press Enter).", {
2172
+ sessionId: z.string().min(1).optional(),
2173
+ text: z.string(),
2174
+ enter: z.boolean().optional()
2175
+ }, { title: "Send Text" }, async (args, extra) => {
2176
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2177
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2178
+ const session = sessions.getSession(sessionId);
2179
+ if (!session) return toolError(`session not found: ${sessionId}`);
2180
+ session.sendText(args.text, { enter: args.enter });
2181
+ recordings.recordStep({
2182
+ type: "sendText",
2183
+ text: args.text,
2184
+ enter: args.enter
2185
+ });
2186
+ return { content: [{
2187
+ type: "text",
2188
+ text: "ok"
2189
+ }] };
2190
+ });
2191
+ tool("core", "press_key", "Send a key or key chord to a session (e.g. Enter, Ctrl+C, Shift+Tab).", {
2192
+ sessionId: z.string().min(1).optional(),
2193
+ key: z.string().min(1)
2194
+ }, { title: "Press Key" }, async (args, extra) => {
2195
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2196
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2197
+ const session = sessions.getSession(sessionId);
2198
+ if (!session) return toolError(`session not found: ${sessionId}`);
2199
+ try {
2200
+ session.pressKey(args.key);
2201
+ recordings.recordStep({
2202
+ type: "pressKey",
2203
+ key: args.key
2204
+ });
2205
+ return { content: [{
2206
+ type: "text",
2207
+ text: "ok"
2208
+ }] };
2209
+ } catch (error) {
2210
+ return toolError(error.message);
2211
+ }
2212
+ });
2213
+ tool("core", "snapshot_text", "Capture plain text from the visible screen or full buffer (best for stable assertions/goldens).", {
2214
+ sessionId: z.string().min(1).optional(),
2215
+ scope: z.enum(["visible", "buffer"]).optional(),
2216
+ trimRight: z.boolean().optional(),
2217
+ trimBottom: z.boolean().optional(),
2218
+ maxLines: z.number().int().positive().optional(),
2219
+ tailLines: z.number().int().positive().optional(),
2220
+ mask: z.array(textMaskRuleSchema).optional()
2221
+ }, {
2222
+ title: "Snapshot Text",
2223
+ readOnlyHint: true,
2224
+ idempotentHint: true
2225
+ }, async (args, extra) => {
2226
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2227
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2228
+ const session = sessions.getSession(sessionId);
2229
+ if (!session) return toolError(`session not found: ${sessionId}`);
2230
+ if (args.maxLines !== void 0 && args.tailLines !== void 0) return toolError("snapshot_text: maxLines and tailLines are mutually exclusive");
2231
+ let text;
2232
+ let hash;
2233
+ try {
2234
+ ({text, hash} = await session.snapshotText({
2235
+ scope: args.scope,
2236
+ trimRight: args.trimRight ?? true,
2237
+ trimBottom: args.trimBottom ?? true,
2238
+ maxLines: args.maxLines,
2239
+ tailLines: args.tailLines,
2240
+ mask: args.mask
2241
+ }));
2242
+ } catch (error) {
2243
+ return toolError(error.message);
2244
+ }
2245
+ return {
2246
+ content: [{
2247
+ type: "text",
2248
+ text
2249
+ }],
2250
+ structuredContent: {
2251
+ sessionId,
2252
+ hash
2253
+ }
2254
+ };
2255
+ });
2256
+ tool("debug", "snapshot_ansi", "Capture ANSI-rendered snapshot (debug/human inspection; less stable than plain text).", {
2257
+ sessionId: z.string().min(1).optional(),
2258
+ scope: z.enum(["visible", "buffer"]).optional(),
2259
+ trimRight: z.boolean().optional(),
2260
+ trimBottom: z.boolean().optional(),
2261
+ maxLines: z.number().int().positive().optional(),
2262
+ tailLines: z.number().int().positive().optional(),
2263
+ mask: z.array(textMaskRuleSchema).optional()
2264
+ }, {
2265
+ title: "Snapshot ANSI",
2266
+ readOnlyHint: true,
2267
+ idempotentHint: true
2268
+ }, async (args, extra) => {
2269
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2270
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2271
+ const session = sessions.getSession(sessionId);
2272
+ if (!session) return toolError(`session not found: ${sessionId}`);
2273
+ if (args.maxLines !== void 0 && args.tailLines !== void 0) return toolError("snapshot_ansi: maxLines and tailLines are mutually exclusive");
2274
+ let ansi;
2275
+ let plain;
2276
+ let hash;
2277
+ try {
2278
+ ({ansi, plain, hash} = await session.snapshotAnsi({
2279
+ scope: args.scope,
2280
+ trimRight: args.trimRight ?? true,
2281
+ trimBottom: args.trimBottom ?? true,
2282
+ maxLines: args.maxLines,
2283
+ tailLines: args.tailLines,
2284
+ mask: args.mask
2285
+ }));
2286
+ } catch (error) {
2287
+ return toolError(error.message);
2288
+ }
2289
+ return {
2290
+ content: [{
2291
+ type: "text",
2292
+ text: ansi
2293
+ }],
2294
+ structuredContent: {
2295
+ sessionId,
2296
+ hash,
2297
+ plain
2298
+ }
2299
+ };
2300
+ });
2301
+ tool("recording", "mark", "Add a marker to the session trace (used for recording/checkpoints).", {
2302
+ sessionId: z.string().min(1).optional(),
2303
+ label: z.string().optional()
2304
+ }, { title: "Mark Trace" }, async (args, extra) => {
2305
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2306
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2307
+ const session = sessions.getSession(sessionId);
2308
+ if (!session) return toolError(`session not found: ${sessionId}`);
2309
+ session.mark(args.label);
2310
+ recordings.recordStep({
2311
+ type: "mark",
2312
+ label: args.label
2313
+ });
2314
+ await recordings.recordCheckpoint({
2315
+ session,
2316
+ label: args.label
2317
+ });
2318
+ return { content: [{
2319
+ type: "text",
2320
+ text: "ok"
2321
+ }] };
2322
+ });
2323
+ tool("core", "wait_for_text", "Wait until a text/regex appears in the session (polling).", {
2324
+ sessionId: z.string().min(1).optional(),
2325
+ scope: z.enum(["visible", "buffer"]).optional(),
2326
+ text: z.string().optional(),
2327
+ regex: z.string().optional(),
2328
+ timeoutMs: z.number().int().optional(),
2329
+ intervalMs: z.number().int().optional(),
2330
+ includeText: z.boolean().optional()
2331
+ }, {
2332
+ title: "Wait For Text",
2333
+ readOnlyHint: true,
2334
+ idempotentHint: true
2335
+ }, async (args, extra) => {
2336
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2337
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2338
+ const session = sessions.getSession(sessionId);
2339
+ if (!session) return toolError(`session not found: ${sessionId}`);
2340
+ if (!args.text && !args.regex) return toolError("either text or regex must be provided");
2341
+ const regex = args.regex ? new RegExp(args.regex) : void 0;
2342
+ const result = await session.waitForText({
2343
+ scope: args.scope,
2344
+ text: args.text,
2345
+ regex,
2346
+ timeoutMs: args.timeoutMs ?? 1e4,
2347
+ intervalMs: args.intervalMs ?? 100
2348
+ });
2349
+ recordings.recordStep({
2350
+ type: "waitForText",
2351
+ scope: args.scope,
2352
+ text: args.text,
2353
+ regex: args.regex,
2354
+ timeoutMs: args.timeoutMs,
2355
+ intervalMs: args.intervalMs
2356
+ });
2357
+ const structuredContent = {
2358
+ sessionId,
2359
+ found: result.found,
2360
+ hash: result.hash
2361
+ };
2362
+ if (args.includeText ?? false) structuredContent.text = result.text;
2363
+ return {
2364
+ content: [{
2365
+ type: "text",
2366
+ text: result.found ? "found" : "not_found"
2367
+ }],
2368
+ structuredContent
2369
+ };
2370
+ });
2371
+ tool("core", "wait_for_stable_screen", "Wait until consecutive text snapshots remain unchanged for a quiet window (reduce flakiness).", {
2372
+ sessionId: z.string().min(1).optional(),
2373
+ timeoutMs: z.number().int().optional(),
2374
+ quietMs: z.number().int().optional(),
2375
+ intervalMs: z.number().int().optional(),
2376
+ includeText: z.boolean().optional()
2377
+ }, {
2378
+ title: "Wait For Stable Screen",
2379
+ readOnlyHint: true,
2380
+ idempotentHint: true
2381
+ }, async (args, extra) => {
2382
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2383
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2384
+ const session = sessions.getSession(sessionId);
2385
+ if (!session) return toolError(`session not found: ${sessionId}`);
2386
+ const result = await session.waitForStableScreen({
2387
+ timeoutMs: args.timeoutMs ?? 1e4,
2388
+ quietMs: args.quietMs ?? 400,
2389
+ intervalMs: args.intervalMs ?? 80
2390
+ });
2391
+ recordings.recordStep({
2392
+ type: "waitForStableScreen",
2393
+ ...args
2394
+ });
2395
+ const structuredContent = {
2396
+ sessionId,
2397
+ stable: result.stable,
2398
+ hash: result.hash
2399
+ };
2400
+ if (args.includeText ?? false) structuredContent.text = result.text;
2401
+ return {
2402
+ content: [{
2403
+ type: "text",
2404
+ text: result.stable ? "stable" : "unstable"
2405
+ }],
2406
+ structuredContent
2407
+ };
2408
+ });
2409
+ tool("core", "assert", "Verify screen content. Use this to check if a test passed or failed (e.g., 'check if X is visible'). Supports exact text, regex, or semantic AI verification.", {
2410
+ sessionId: z.string().min(1).optional(),
2411
+ scope: z.enum(["visible", "buffer"]).optional(),
2412
+ description: z.string().optional(),
2413
+ text: z.string().optional(),
2414
+ regex: z.string().optional(),
2415
+ useAI: z.boolean().optional()
2416
+ }, {
2417
+ title: "Assert",
2418
+ readOnlyHint: true,
2419
+ idempotentHint: true
2420
+ }, async (args, extra) => {
2421
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2422
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2423
+ const session = sessions.getSession(sessionId);
2424
+ if (!session) return toolError(`session not found: ${sessionId}`);
2425
+ if (!args.text && !args.regex && !args.useAI) return toolError("must provide text, regex, or useAI: true");
2426
+ const snapshot = await session.snapshotText({
2427
+ scope: args.scope ?? "visible",
2428
+ captureFrame: true
2429
+ });
2430
+ let found = true;
2431
+ if (args.text || args.regex) {
2432
+ found = false;
2433
+ if (args.text && snapshot.text.includes(args.text)) found = true;
2434
+ if (args.regex) {
2435
+ let re;
2436
+ try {
2437
+ re = new RegExp(args.regex);
2438
+ } catch {
2439
+ return toolError(`invalid regex: ${args.regex}`);
2440
+ }
2441
+ if (re.test(snapshot.text)) found = true;
2442
+ }
2443
+ }
2444
+ if (args.text || args.regex) recordings.recordStep({
2445
+ type: "assert",
2446
+ scope: args.scope,
2447
+ text: args.text,
2448
+ regex: args.regex,
2449
+ description: args.description
2450
+ });
2451
+ if (args.useAI) recordings.recordStep({
2452
+ type: "assertSemantic",
2453
+ prompt: args.description || "Check screen content",
2454
+ description: args.description
2455
+ });
2456
+ if (!found) return toolError(`assertion failed: ${args.description || "pattern match"}`, {
2457
+ sessionId,
2458
+ text: snapshot.text
2459
+ });
2460
+ return {
2461
+ content: [{
2462
+ type: "text",
2463
+ text: "ok"
2464
+ }],
2465
+ structuredContent: {
2466
+ sessionId,
2467
+ found
2468
+ }
2469
+ };
2470
+ });
2471
+ tool("core", "snapshot_view", "Capture a formatted, human-readable snapshot view (includes meta + optional line numbers).", {
2472
+ sessionId: z.string().min(1).optional(),
2473
+ scope: z.enum(["visible", "buffer"]).optional(),
2474
+ trimRight: z.boolean().optional(),
2475
+ trimBottom: z.boolean().optional(),
2476
+ maxLines: z.number().int().positive().optional(),
2477
+ tailLines: z.number().int().positive().optional(),
2478
+ lineNumbers: z.boolean().optional(),
2479
+ mask: z.array(textMaskRuleSchema).optional()
2480
+ }, {
2481
+ title: "Snapshot View",
2482
+ readOnlyHint: true,
2483
+ idempotentHint: true
2484
+ }, async (args, extra) => {
2485
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2486
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2487
+ const session = sessions.getSession(sessionId);
2488
+ if (!session) return toolError(`session not found: ${sessionId}`);
2489
+ if (args.maxLines !== void 0 && args.tailLines !== void 0) return toolError("snapshot_view: maxLines and tailLines are mutually exclusive");
2490
+ let text;
2491
+ let hash;
2492
+ try {
2493
+ ({text, hash} = await session.snapshotText({
2494
+ scope: args.scope,
2495
+ trimRight: args.trimRight,
2496
+ trimBottom: args.trimBottom ?? true,
2497
+ maxLines: args.maxLines,
2498
+ tailLines: args.tailLines,
2499
+ mask: args.mask
2500
+ }));
2501
+ } catch (error) {
2502
+ return toolError(error.message);
2503
+ }
2504
+ return {
2505
+ content: [{
2506
+ type: "text",
2507
+ text: formatSnapshotView({
2508
+ sessionId,
2509
+ scope: args.scope ?? "visible",
2510
+ hash,
2511
+ lines: text.split("\n"),
2512
+ meta: session.getMeta(),
2513
+ lineNumbers: args.lineNumbers
2514
+ })
2515
+ }],
2516
+ structuredContent: {
2517
+ sessionId,
2518
+ hash
2519
+ }
2520
+ };
2521
+ });
2522
+ tool("debug", "snapshot_view_ansi", "Capture a formatted ANSI snapshot view (includes meta + optional line numbers).", {
2523
+ sessionId: z.string().min(1).optional(),
2524
+ scope: z.enum(["visible", "buffer"]).optional(),
2525
+ trimRight: z.boolean().optional(),
2526
+ trimBottom: z.boolean().optional(),
2527
+ maxLines: z.number().int().positive().optional(),
2528
+ tailLines: z.number().int().positive().optional(),
2529
+ lineNumbers: z.boolean().optional(),
2530
+ mask: z.array(textMaskRuleSchema).optional()
2531
+ }, {
2532
+ title: "Snapshot View (ANSI)",
2533
+ readOnlyHint: true,
2534
+ idempotentHint: true
2535
+ }, async (args, extra) => {
2536
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2537
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2538
+ const session = sessions.getSession(sessionId);
2539
+ if (!session) return toolError(`session not found: ${sessionId}`);
2540
+ if (args.maxLines !== void 0 && args.tailLines !== void 0) return toolError("snapshot_view_ansi: maxLines and tailLines are mutually exclusive");
2541
+ let ansi;
2542
+ let hash;
2543
+ try {
2544
+ ({ansi, hash} = await session.snapshotAnsi({
2545
+ scope: args.scope,
2546
+ trimRight: args.trimRight,
2547
+ trimBottom: args.trimBottom ?? true,
2548
+ maxLines: args.maxLines,
2549
+ tailLines: args.tailLines,
2550
+ mask: args.mask
2551
+ }));
2552
+ } catch (error) {
2553
+ return toolError(error.message);
2554
+ }
2555
+ return {
2556
+ content: [{
2557
+ type: "text",
2558
+ text: formatSnapshotView({
2559
+ sessionId,
2560
+ scope: args.scope ?? "visible",
2561
+ hash,
2562
+ lines: ansi.split("\n"),
2563
+ meta: session.getMeta(),
2564
+ lineNumbers: args.lineNumbers
2565
+ })
2566
+ }],
2567
+ structuredContent: {
2568
+ sessionId,
2569
+ hash
2570
+ }
2571
+ };
2572
+ });
2573
+ tool("script", "run_script", "Run a JSON/TS script via the runner and return artifact paths (prefer this for regression runs).", {
2574
+ scriptPath: z.string().min(1),
2575
+ artifactsDir: z.string().optional(),
2576
+ stepsPath: z.string().optional(),
2577
+ updateGoldens: z.boolean().optional()
2578
+ }, {
2579
+ title: "Run Script",
2580
+ openWorldHint: true,
2581
+ destructiveHint: true
2582
+ }, async (args) => {
2583
+ const result = await runScriptPath(args.scriptPath, {
2584
+ artifactsDir: args.artifactsDir,
2585
+ stepsPath: args.stepsPath,
2586
+ updateGoldens: args.updateGoldens
2587
+ });
2588
+ if (!result.ok) return toolError(result.error, {
2589
+ scriptName: result.scriptName,
2590
+ artifactsDir: result.artifactsDir,
2591
+ castPath: result.castPath,
2592
+ reportPath: result.reportPath,
2593
+ failureArtifacts: result.failureArtifacts
2594
+ });
2595
+ return {
2596
+ content: [{
2597
+ type: "text",
2598
+ text: `ok artifacts=${result.artifactsDir}`
2599
+ }],
2600
+ structuredContent: result
2601
+ };
2602
+ });
2603
+ tool("script", "run_all_scripts", "Run all ptywright scripts (JSON/TS) recursively and generate a Playwright-like suite report: index.html + run.summary.json. 用于:一键批量回归 / 生成总览报告 / CI。Call with no args to use defaults (dir='scripts', suite report in .tmp/run-all/). Returns reportPath+summaryPath; open reportPath in a browser to view. Tip: keep includeEntries='failures' (default) and maxEntries to avoid context bloat.", {
2604
+ dir: z.string().optional(),
2605
+ artifactsRoot: z.string().optional(),
2606
+ stepsPath: z.string().optional(),
2607
+ updateGoldens: z.boolean().optional(),
2608
+ includeEntries: z.enum([
2609
+ "none",
2610
+ "failures",
2611
+ "all"
2612
+ ]).optional(),
2613
+ maxEntries: z.number().int().nonnegative().optional()
2614
+ }, {
2615
+ title: "Run All Scripts (Suite Report)",
2616
+ openWorldHint: true,
2617
+ destructiveHint: true
2618
+ }, async (args) => {
2619
+ try {
2620
+ const includeEntries = args.includeEntries ?? "failures";
2621
+ const maxEntries = args.maxEntries ?? 20;
2622
+ const result = await runAllScripts({
2623
+ dir: args.dir,
2624
+ artifactsRoot: args.artifactsRoot,
2625
+ stepsPath: args.stepsPath,
2626
+ updateGoldens: args.updateGoldens
2627
+ });
2628
+ const failures = result.entries.filter((e) => !e.result.ok);
2629
+ let entries = [];
2630
+ if (includeEntries === "all") entries = result.entries;
2631
+ else if (includeEntries === "failures") entries = failures;
2632
+ let truncatedCount = 0;
2633
+ if (entries.length > maxEntries) {
2634
+ truncatedCount = entries.length - maxEntries;
2635
+ entries = entries.slice(0, maxEntries);
2636
+ }
2637
+ const summaryLines = [
2638
+ result.ok ? "ok" : "failed",
2639
+ `count=${result.entries.length}`,
2640
+ `failures=${failures.length}`,
2641
+ `dir=${result.dir}`,
2642
+ `entries=${entries.length}`,
2643
+ `report=${result.reportPath}`,
2644
+ `summary=${result.summaryPath}`,
2645
+ truncatedCount > 0 ? `truncated=${truncatedCount}` : null
2646
+ ];
2647
+ if (entries.length > 0 && failures.length > 0) for (const f of entries) {
2648
+ if (f.result.ok) continue;
2649
+ summaryLines.push(`- ${f.filePath}: ${f.result.error}`);
2650
+ }
2651
+ return {
2652
+ content: [{
2653
+ type: "text",
2654
+ text: summaryLines.filter(Boolean).join("\n")
2655
+ }],
2656
+ structuredContent: {
2657
+ ok: result.ok,
2658
+ dir: result.dir,
2659
+ suiteDir: result.suiteDir,
2660
+ reportPath: result.reportPath,
2661
+ summaryPath: result.summaryPath,
2662
+ totalCount: result.entries.length,
2663
+ failureCount: failures.length,
2664
+ includeEntries,
2665
+ maxEntries,
2666
+ truncatedCount,
2667
+ entries
2668
+ }
2669
+ };
2670
+ } catch (error) {
2671
+ return toolError(error.message);
2672
+ }
2673
+ });
2674
+ tool("script", "generate_test_from_doc", "Generate test script from documentation (local file or URL). Parses Markdown/HTML/JSON docs to extract test steps and generates executable ptywright scripts.", {
2675
+ source: z.string().min(1).describe("Document path (local file) or URL"),
2676
+ sourceType: z.enum([
2677
+ "local",
2678
+ "url",
2679
+ "auto"
2680
+ ]).optional().describe("Source type (auto-detected if not specified)"),
2681
+ outputDir: z.string().optional().describe("Output directory for generated scripts"),
2682
+ outputFormat: z.enum([
2683
+ "json",
2684
+ "ts",
2685
+ "both"
2686
+ ]).optional().describe("Output format (default: both)"),
2687
+ targetCommand: z.string().optional().describe("Command to test (overrides auto-detected command)"),
2688
+ targetArgs: z.array(z.string()).optional().describe("Arguments for target command"),
2689
+ name: z.string().optional().describe("Test name (auto-generated from doc title if not specified)"),
2690
+ cols: z.number().int().positive().optional().describe("Terminal columns (default: 80)"),
2691
+ rows: z.number().int().positive().optional().describe("Terminal rows (default: 24)")
2692
+ }, {
2693
+ title: "Generate Test from Documentation",
2694
+ openWorldHint: true,
2695
+ destructiveHint: true
2696
+ }, async (args) => {
2697
+ try {
2698
+ const result = await generateTestFromDoc({
2699
+ source: args.source,
2700
+ sourceType: args.sourceType,
2701
+ outputDir: args.outputDir,
2702
+ outputFormat: args.outputFormat,
2703
+ targetCommand: args.targetCommand,
2704
+ targetArgs: args.targetArgs,
2705
+ name: args.name,
2706
+ cols: args.cols,
2707
+ rows: args.rows
2708
+ });
2709
+ if (!result.ok) return toolError(result.error ?? "Failed to generate test", {
2710
+ warnings: result.warnings,
2711
+ parsed: result.parsed
2712
+ });
2713
+ return {
2714
+ content: [{
2715
+ type: "text",
2716
+ text: [
2717
+ `Generated test: ${result.name}`,
2718
+ `Steps: ${result.stepCount}`,
2719
+ result.jsonPath ? `JSON: ${result.jsonPath}` : null,
2720
+ result.tsPath ? `TypeScript: ${result.tsPath}` : null,
2721
+ result.warnings.length > 0 ? `Warnings: ${result.warnings.join("; ")}` : null
2722
+ ].filter(Boolean).join("\n")
2723
+ }],
2724
+ structuredContent: result
2725
+ };
2726
+ } catch (error) {
2727
+ return toolError(error.message);
2728
+ }
2729
+ });
2730
+ tool("recording", "start_script_recording", "Start recording MCP tool calls into a replayable script (with optional golden checkpoints via mark()).", {
2731
+ name: z.string().min(1),
2732
+ outPath: z.string().optional(),
2733
+ goldenDir: z.string().optional(),
2734
+ overwrite: z.boolean().optional(),
2735
+ scope: z.enum(["visible", "buffer"]).optional(),
2736
+ trimRight: z.boolean().optional(),
2737
+ trimBottom: z.boolean().optional(),
2738
+ mask: z.array(textMaskRuleSchema).optional()
2739
+ }, {
2740
+ title: "Start Script Recording",
2741
+ openWorldHint: true
2742
+ }, async (args) => {
2743
+ try {
2744
+ const status = recordings.start({
2745
+ name: args.name,
2746
+ outPath: args.outPath,
2747
+ goldenDir: args.goldenDir,
2748
+ overwrite: args.overwrite,
2749
+ checkpoint: {
2750
+ scope: args.scope ?? "visible",
2751
+ trimRight: args.trimRight ?? true,
2752
+ trimBottom: args.trimBottom ?? true,
2753
+ mask: args.mask
2754
+ }
2755
+ });
2756
+ return {
2757
+ content: [{
2758
+ type: "text",
2759
+ text: `recording ${status.recordingId}`
2760
+ }],
2761
+ structuredContent: status
2762
+ };
2763
+ } catch (error) {
2764
+ return toolError(error.message);
2765
+ }
2766
+ });
2767
+ tool("recording", "stop_script_recording", "Stop recording and optionally write the script + goldens to disk.", {
2768
+ recordingId: z.string().min(1),
2769
+ writeFiles: z.boolean().optional()
2770
+ }, {
2771
+ title: "Stop Script Recording",
2772
+ destructiveHint: true
2773
+ }, async (args) => {
2774
+ try {
2775
+ const result = recordings.stop({
2776
+ recordingId: args.recordingId,
2777
+ writeFiles: args.writeFiles
2778
+ });
2779
+ return {
2780
+ content: [{
2781
+ type: "text",
2782
+ text: `ok script=${result.scriptPath ?? ""}`
2783
+ }],
2784
+ structuredContent: {
2785
+ scriptPath: result.scriptPath,
2786
+ goldenPaths: result.goldenPaths
2787
+ }
2788
+ };
2789
+ } catch (error) {
2790
+ return toolError(error.message);
2791
+ }
2792
+ });
2793
+ tool("core", "close_session", "Close a running session.", { sessionId: z.string().min(1).optional() }, {
2794
+ title: "Close Session",
2795
+ destructiveHint: true
2796
+ }, async (args, extra) => {
2797
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2798
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2799
+ if (!sessions.closeSession(sessionId)) return toolError(`session not found: ${sessionId}`);
2800
+ if (getSelectedSessionId(extra) === sessionId) clearSelectedSessionId(extra);
2801
+ return { content: [{
2802
+ type: "text",
2803
+ text: "closed"
2804
+ }] };
2805
+ });
2806
+ tool("core", "list_sessions", "List all active sessions.", {}, { title: "List Sessions" }, async (_args, _extra) => {
2807
+ const all = sessions.listSessions().map((s) => ({
2808
+ id: s.id,
2809
+ cols: s.cols,
2810
+ rows: s.rows,
2811
+ meta: s.getMeta()
2812
+ }));
2813
+ return {
2814
+ content: [{
2815
+ type: "text",
2816
+ text: JSON.stringify(all, null, 2)
2817
+ }],
2818
+ structuredContent: { sessions: all }
2819
+ };
2820
+ });
2821
+ const routineStepSchema = z.object({
2822
+ action: z.enum([
2823
+ "sendText",
2824
+ "pressKey",
2825
+ "wait",
2826
+ "assert",
2827
+ "snapshot"
2828
+ ]),
2829
+ text: z.string().optional(),
2830
+ enter: z.boolean().optional(),
2831
+ key: z.string().optional(),
2832
+ waitFor: z.string().optional(),
2833
+ regex: z.string().optional(),
2834
+ timeoutMs: z.number().int().optional(),
2835
+ description: z.string().optional()
2836
+ });
2837
+ tool("script", "run_routine", "PRIMARY INTERACTION TOOL. Execute a multi-step test scenario (type, key, wait, assert) in one go. Use this whenever asked to 'test', 'verify', 'do', or 'check' a workflow. It handles delays and snapshots automatically.", {
2838
+ sessionId: z.string().min(1).optional(),
2839
+ steps: z.array(routineStepSchema).min(1),
2840
+ saveReport: z.boolean().optional(),
2841
+ reportPath: z.string().optional()
2842
+ }, {
2843
+ title: "Run Routine",
2844
+ openWorldHint: true
2845
+ }, async (args, extra) => {
2846
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2847
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2848
+ const session = sessions.getSession(sessionId);
2849
+ if (!session) return toolError(`session not found: ${sessionId}`);
2850
+ const results = [];
2851
+ let failed = false;
2852
+ let failedStep = null;
2853
+ for (let i = 0; i < args.steps.length; i++) {
2854
+ const step = args.steps[i];
2855
+ if (!step) continue;
2856
+ const result = {
2857
+ index: i + 1,
2858
+ action: step.action,
2859
+ description: step.description,
2860
+ ok: true
2861
+ };
2862
+ try {
2863
+ if (step.action === "sendText" && step.text !== void 0) session.sendText(step.text, { enter: step.enter });
2864
+ else if (step.action === "pressKey" && step.key) session.pressKey(step.key);
2865
+ else if (step.action === "wait") if (step.waitFor || step.regex) {
2866
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
2867
+ if (!(await session.waitForText({
2868
+ text: step.waitFor,
2869
+ regex,
2870
+ timeoutMs: step.timeoutMs ?? 1e4,
2871
+ intervalMs: 100
2872
+ })).found) throw new Error(`wait failed: ${step.waitFor || step.regex}`);
2873
+ } else await session.waitForStableScreen({
2874
+ timeoutMs: step.timeoutMs ?? 5e3,
2875
+ quietMs: 300,
2876
+ intervalMs: 80
2877
+ });
2878
+ else if (step.action === "assert") {
2879
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
2880
+ if (!(await session.waitForText({
2881
+ text: step.waitFor,
2882
+ regex,
2883
+ timeoutMs: 0,
2884
+ intervalMs: 0
2885
+ })).found) throw new Error(`assert failed: ${step.description || step.waitFor || step.regex}`);
2886
+ }
2887
+ const snapshot = await session.snapshotText({
2888
+ scope: "visible",
2889
+ trimRight: true,
2890
+ trimBottom: true,
2891
+ captureFrame: true
2892
+ });
2893
+ result.snapshot = snapshot.text;
2894
+ result.hash = snapshot.hash;
2895
+ } catch (error) {
2896
+ result.ok = false;
2897
+ result.error = error.message;
2898
+ failed = true;
2899
+ failedStep = i + 1;
2900
+ try {
2901
+ const snapshot = await session.snapshotText({
2902
+ scope: "visible",
2903
+ trimRight: true,
2904
+ trimBottom: true,
2905
+ captureFrame: true
2906
+ });
2907
+ result.snapshot = snapshot.text;
2908
+ result.hash = snapshot.hash;
2909
+ } catch {}
2910
+ }
2911
+ results.push(result);
2912
+ if (failed) break;
2913
+ }
2914
+ const summary = failed ? `failed at step ${failedStep}: ${results[results.length - 1]?.error}` : `ok, ${results.length} steps completed`;
2915
+ let reportPath = args.reportPath;
2916
+ if (args.saveReport ?? true) try {
2917
+ const html = await generateTraceReportHtml((await session.snapshotCast()).cast, {
2918
+ scriptName: "routine",
2919
+ result: {
2920
+ ok: !failed,
2921
+ error: results[results.length - 1]?.error
2922
+ },
2923
+ steps: results.map((r) => ({
2924
+ index: r.index,
2925
+ step: {
2926
+ type: r.action,
2927
+ description: r.description
2928
+ },
2929
+ ok: r.ok,
2930
+ error: r.error,
2931
+ after: r.snapshot ? {
2932
+ text: r.snapshot,
2933
+ hash: r.hash ?? "",
2934
+ kind: "view"
2935
+ } : void 0
2936
+ }))
2937
+ });
2938
+ if (!reportPath) {
2939
+ const tmpDir = join(tmpdir(), "ptywright-routines");
2940
+ mkdirSync(tmpDir, { recursive: true });
2941
+ reportPath = join(tmpDir, `routine-${sessionId}-${Date.now()}.html`);
2942
+ }
2943
+ writeFileSync(reportPath, html);
2944
+ ensureAsciinemaPlayerAssets(reportPath);
2945
+ } catch {}
2946
+ return {
2947
+ content: [{
2948
+ type: "text",
2949
+ text: reportPath ? `${summary}
2950
+
2951
+ Report generated: ${reportPath}
2952
+ Open in browser to view step-by-step timeline.` : summary
2953
+ }],
2954
+ structuredContent: {
2955
+ sessionId,
2956
+ ok: !failed,
2957
+ stepCount: results.length,
2958
+ failedStep,
2959
+ reportPath,
2960
+ results
2961
+ }
2962
+ };
2963
+ });
2964
+ tool("core", "inspect_failure", "Inspect the last failure state of a session. Returns the last screen snapshot and any error information captured.", {
2965
+ sessionId: z.string().min(1).optional(),
2966
+ includeFrames: z.boolean().optional(),
2967
+ maxFrames: z.number().int().positive().optional()
2968
+ }, {
2969
+ title: "Inspect Failure",
2970
+ readOnlyHint: true,
2971
+ idempotentHint: true
2972
+ }, async (args, extra) => {
2973
+ const sessionId = args.sessionId ?? getSelectedSessionId(extra);
2974
+ if (!sessionId) return toolError("sessionId is required (provide sessionId or call select_session)");
2975
+ const session = sessions.getSession(sessionId);
2976
+ if (!session) return toolError(`session not found: ${sessionId}`);
2977
+ const closeReason = session.getCloseReason();
2978
+ const meta = session.getMeta();
2979
+ let currentSnapshot = null;
2980
+ try {
2981
+ currentSnapshot = await session.snapshotText({
2982
+ scope: "visible",
2983
+ trimRight: true,
2984
+ trimBottom: true,
2985
+ captureFrame: true
2986
+ });
2987
+ } catch {}
2988
+ const frames = session.getSnapshotFrames();
2989
+ const includeFrames = args.includeFrames ?? false;
2990
+ const maxFrames = args.maxFrames ?? 5;
2991
+ const recentFrames = includeFrames ? frames.slice(-maxFrames) : [];
2992
+ return {
2993
+ content: [{
2994
+ type: "text",
2995
+ text: currentSnapshot ? formatSnapshotView({
2996
+ sessionId,
2997
+ scope: "visible",
2998
+ hash: currentSnapshot.hash,
2999
+ lines: currentSnapshot.text.split("\n"),
3000
+ meta,
3001
+ lineNumbers: true
3002
+ }) : "(no snapshot available)"
3003
+ }],
3004
+ structuredContent: {
3005
+ sessionId,
3006
+ closeReason,
3007
+ meta,
3008
+ currentSnapshot: currentSnapshot ? {
3009
+ text: currentSnapshot.text,
3010
+ hash: currentSnapshot.hash
3011
+ } : null,
3012
+ recentFrames: recentFrames.map((f) => ({
3013
+ atMs: f.atMs,
3014
+ hash: f.hash
3015
+ })),
3016
+ isClosed: session.isClosed()
3017
+ }
3018
+ };
3019
+ });
3020
+ return {
3021
+ server,
3022
+ sessions
3023
+ };
3024
+ }
3025
+ function resolveCapabilities(capabilities, envValue) {
3026
+ const requested = capabilities?.length ? capabilities : parseCapabilitiesEnv(envValue);
3027
+ const normalized = /* @__PURE__ */ new Set();
3028
+ let all = false;
3029
+ for (const cap of requested) {
3030
+ if (cap === "all") {
3031
+ all = true;
3032
+ continue;
3033
+ }
3034
+ normalized.add(cap);
3035
+ }
3036
+ if (!all && normalized.size === 0) all = true;
3037
+ return {
3038
+ all,
3039
+ enabled: normalized
3040
+ };
3041
+ }
3042
+ function parseCapabilitiesEnv(envValue) {
3043
+ if (!envValue?.trim()) return [];
3044
+ const parts = envValue.split(/[,\s]+/g).map((p) => p.trim().toLowerCase()).filter(Boolean);
3045
+ const out = [];
3046
+ for (const p of parts) if (p === "all") out.push("all");
3047
+ else if (p === "core") out.push("core");
3048
+ else if (p === "debug") out.push("debug");
3049
+ else if (p === "script" || p === "scripts" || p === "runner" || p === "run") out.push("script");
3050
+ else if (p === "recording" || p === "record" || p === "rec") out.push("recording");
3051
+ else throw new Error(`unknown PTYWRIGHT_CAPS capability: ${p}`);
3052
+ return out;
3053
+ }
3054
+ function toolError(message, extra = {}) {
3055
+ return {
3056
+ isError: true,
3057
+ content: [{
3058
+ type: "text",
3059
+ text: message
3060
+ }],
3061
+ structuredContent: {
3062
+ error: message,
3063
+ ...extra
3064
+ }
3065
+ };
3066
+ }
3067
+ //#endregion
3068
+ export { readScriptManifestPath as a, resolveScriptManifestPath as c, resolveScriptRunSummaryPath as d, runScriptPath as f, findScriptSummaryManifest as i, validateScriptManifest as l, runAllScripts as n, relocateScriptManifestCommands as o, SCRIPT_MANIFEST_FILE_NAME as r, resolveManifestPrimaryPath as s, createPtywrightServer as t, readScriptRunSummaryPath as u };