ptywright 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +459 -116
- package/dist/agent.mjs +2 -0
- package/dist/bin/ptywright.mjs +6 -0
- package/dist/cli-DIUx2w6X.mjs +3587 -0
- package/dist/cli.mjs +2 -0
- package/{src/index.ts → dist/index.mjs} +7 -9
- package/dist/mcp.mjs +2 -0
- package/dist/pty-cassette.mjs +24 -0
- package/dist/pty_like-Cpkh_O9B.mjs +404 -0
- package/dist/runner-DzZlFrt1.mjs +1897 -0
- package/dist/runner-zApMYWZx.mjs +3257 -0
- package/dist/script.mjs +2 -0
- package/dist/server-VHuEWWj_.mjs +3068 -0
- package/dist/session.mjs +2 -0
- package/dist/terminal_session-DopC7Xg6.mjs +893 -0
- package/package.json +28 -21
- package/schemas/ptywright-agent-cassette.schema.json +57 -0
- package/schemas/ptywright-agent-check.schema.json +122 -0
- package/schemas/ptywright-agent-manifest.schema.json +107 -0
- package/schemas/ptywright-agent-promote.schema.json +146 -0
- package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
- package/schemas/ptywright-agent-run.schema.json +126 -0
- package/schemas/ptywright-agent.schema.json +182 -0
- package/schemas/ptywright-pty-cassette.schema.json +86 -0
- package/schemas/ptywright-script-manifest.schema.json +75 -0
- package/schemas/ptywright-script-run-summary.schema.json +114 -0
- package/schemas/ptywright-script.schema.json +55 -3
- package/skills/ptywright-testing/SKILL.md +53 -33
- package/bin/ptywright +0 -4
- package/src/cli.ts +0 -414
- package/src/generator/doc_parser.ts +0 -341
- package/src/generator/generate.ts +0 -161
- package/src/generator/index.ts +0 -10
- package/src/generator/script_generator.ts +0 -209
- package/src/generator/step_extractor.ts +0 -397
- package/src/mcp/http_server.ts +0 -174
- package/src/mcp/script_recording.ts +0 -238
- package/src/mcp/server.ts +0 -1348
- package/src/pty/bun_pty_adapter.ts +0 -34
- package/src/pty/bun_terminal_adapter.ts +0 -149
- package/src/pty/pty_adapter.ts +0 -31
- package/src/script/dsl.ts +0 -188
- package/src/script/module.ts +0 -43
- package/src/script/path.ts +0 -151
- package/src/script/run.ts +0 -108
- package/src/script/run_all.ts +0 -229
- package/src/script/runner.ts +0 -983
- package/src/script/schema.ts +0 -237
- package/src/script/steps/assert_snapshot_equals.ts +0 -21
- package/src/script/steps/index.ts +0 -2
- package/src/script/suite_report.ts +0 -626
- package/src/session/session_manager.ts +0 -145
- package/src/session/terminal_session.ts +0 -473
- package/src/terminal/ansi.ts +0 -142
- package/src/terminal/keys.ts +0 -180
- package/src/terminal/mask.ts +0 -70
- package/src/terminal/mouse.ts +0 -75
- package/src/terminal/snapshot.ts +0 -196
- package/src/terminal/style.ts +0 -121
- package/src/terminal/view.ts +0 -49
- package/src/trace/asciicast.ts +0 -20
- package/src/trace/asciinema_player_assets.ts +0 -44
- package/src/trace/cast_to_txt.ts +0 -116
- package/src/trace/recorder.ts +0 -110
- package/src/trace/report.ts +0 -2092
- package/src/types.ts +0 -86
- package/src/util/hash.ts +0 -8
- package/src/util/sleep.ts +0 -5
|
@@ -0,0 +1,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('"', """) + '">' + label + "</a>";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function escapeText(s) {
|
|
946
|
+
return (s || "")
|
|
947
|
+
.replaceAll("&", "&")
|
|
948
|
+
.replaceAll("<", "<")
|
|
949
|
+
.replaceAll(">", ">");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"").replace(/'/g, "'").replace(/ /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.2.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 };
|