ptywright 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +459 -116
  2. package/dist/agent.mjs +2 -0
  3. package/dist/bin/ptywright.mjs +6 -0
  4. package/dist/cli-DIUx2w6X.mjs +3587 -0
  5. package/dist/cli.mjs +2 -0
  6. package/{src/index.ts → dist/index.mjs} +7 -9
  7. package/dist/mcp.mjs +2 -0
  8. package/dist/pty-cassette.mjs +24 -0
  9. package/dist/pty_like-Cpkh_O9B.mjs +404 -0
  10. package/dist/runner-DzZlFrt1.mjs +1897 -0
  11. package/dist/runner-zApMYWZx.mjs +3257 -0
  12. package/dist/script.mjs +2 -0
  13. package/dist/server-VHuEWWj_.mjs +3068 -0
  14. package/dist/session.mjs +2 -0
  15. package/dist/terminal_session-DopC7Xg6.mjs +893 -0
  16. package/package.json +28 -21
  17. package/schemas/ptywright-agent-cassette.schema.json +57 -0
  18. package/schemas/ptywright-agent-check.schema.json +122 -0
  19. package/schemas/ptywright-agent-manifest.schema.json +107 -0
  20. package/schemas/ptywright-agent-promote.schema.json +146 -0
  21. package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
  22. package/schemas/ptywright-agent-run.schema.json +126 -0
  23. package/schemas/ptywright-agent.schema.json +182 -0
  24. package/schemas/ptywright-pty-cassette.schema.json +86 -0
  25. package/schemas/ptywright-script-manifest.schema.json +75 -0
  26. package/schemas/ptywright-script-run-summary.schema.json +114 -0
  27. package/schemas/ptywright-script.schema.json +55 -3
  28. package/skills/ptywright-testing/SKILL.md +53 -33
  29. package/bin/ptywright +0 -4
  30. package/src/cli.ts +0 -414
  31. package/src/generator/doc_parser.ts +0 -341
  32. package/src/generator/generate.ts +0 -161
  33. package/src/generator/index.ts +0 -10
  34. package/src/generator/script_generator.ts +0 -209
  35. package/src/generator/step_extractor.ts +0 -397
  36. package/src/mcp/http_server.ts +0 -174
  37. package/src/mcp/script_recording.ts +0 -238
  38. package/src/mcp/server.ts +0 -1348
  39. package/src/pty/bun_pty_adapter.ts +0 -34
  40. package/src/pty/bun_terminal_adapter.ts +0 -149
  41. package/src/pty/pty_adapter.ts +0 -31
  42. package/src/script/dsl.ts +0 -188
  43. package/src/script/module.ts +0 -43
  44. package/src/script/path.ts +0 -151
  45. package/src/script/run.ts +0 -108
  46. package/src/script/run_all.ts +0 -229
  47. package/src/script/runner.ts +0 -983
  48. package/src/script/schema.ts +0 -237
  49. package/src/script/steps/assert_snapshot_equals.ts +0 -21
  50. package/src/script/steps/index.ts +0 -2
  51. package/src/script/suite_report.ts +0 -626
  52. package/src/session/session_manager.ts +0 -145
  53. package/src/session/terminal_session.ts +0 -473
  54. package/src/terminal/ansi.ts +0 -142
  55. package/src/terminal/keys.ts +0 -180
  56. package/src/terminal/mask.ts +0 -70
  57. package/src/terminal/mouse.ts +0 -75
  58. package/src/terminal/snapshot.ts +0 -196
  59. package/src/terminal/style.ts +0 -121
  60. package/src/terminal/view.ts +0 -49
  61. package/src/trace/asciicast.ts +0 -20
  62. package/src/trace/asciinema_player_assets.ts +0 -44
  63. package/src/trace/cast_to_txt.ts +0 -116
  64. package/src/trace/recorder.ts +0 -110
  65. package/src/trace/report.ts +0 -2092
  66. package/src/types.ts +0 -86
  67. package/src/util/hash.ts +0 -8
  68. package/src/util/sleep.ts +0 -5
@@ -1,983 +0,0 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
-
4
- import { SessionManager } from "../session/session_manager";
5
- import { formatSnapshotView } from "../terminal/view";
6
- import { ensureAsciinemaPlayerAssets } from "../trace/asciinema_player_assets";
7
- import { generateTraceReportHtml } from "../trace/report";
8
- import type { TraceReportArtifacts, TraceReportResult } from "../trace/report";
9
- import { sleep } from "../util/sleep";
10
-
11
- import type { TextMaskRule } from "../terminal/mask";
12
- import { scriptSchema } from "./schema";
13
- import type { Script, ScriptStep } from "./schema";
14
-
15
- export type SnapshotRecord = {
16
- kind: string;
17
- hash: string;
18
- text: string;
19
- };
20
-
21
- export type ScriptCustomStep<Name extends string = string, Payload = unknown> = {
22
- type: "custom";
23
- name: Name;
24
- payload?: Payload;
25
- };
26
-
27
- export type ScriptRunnerContext = {
28
- session: ReturnType<SessionManager["launchSession"]>;
29
- stepIndex: number;
30
- last: SnapshotRecord | null;
31
- snapshots: Map<string, SnapshotRecord>;
32
- artifactsDir: string;
33
- resolveArtifactPath: (path: string) => string;
34
- resolveGoldenPath: (path: string) => string;
35
- updateGoldens: boolean;
36
- captureSnapshot: (
37
- step: Omit<Extract<ScriptStep, { type: "snapshot" }>, "type">,
38
- ) => Promise<SnapshotRecord>;
39
- getSnapshot: (from?: string) => SnapshotRecord;
40
- writeArtifactText: (path: string, text: string) => void;
41
- assertGoldenText: (path: string, text: string) => void;
42
- };
43
-
44
- type CustomStepHandlerImpl<Payload> = {
45
- bivarianceHack(
46
- ctx: ScriptRunnerContext,
47
- step: ScriptCustomStep<string, Payload>,
48
- ): Promise<SnapshotRecord | void> | SnapshotRecord | void;
49
- }["bivarianceHack"];
50
-
51
- export type CustomStepHandler<Payload = unknown> = CustomStepHandlerImpl<Payload>;
52
-
53
- type RunScriptOptions = {
54
- artifactsDir?: string;
55
- updateGoldens?: boolean;
56
- steps?: Record<string, CustomStepHandler>;
57
- };
58
-
59
- export async function runScriptFile(
60
- scriptPath: string,
61
- options?: RunScriptOptions,
62
- ): Promise<{ ok: true; artifactsDir: string }> {
63
- const raw = await Bun.file(scriptPath).text();
64
- const parsedJson = JSON.parse(raw) as unknown;
65
- const baseName = basename(scriptPath, extname(scriptPath));
66
- const withName =
67
- parsedJson &&
68
- typeof parsedJson === "object" &&
69
- !Array.isArray(parsedJson) &&
70
- !("name" in parsedJson)
71
- ? { ...parsedJson, name: baseName }
72
- : parsedJson;
73
-
74
- return runScript(withName, options);
75
- }
76
-
77
- export async function runScript(
78
- script: unknown,
79
- options?: RunScriptOptions,
80
- ): Promise<{ ok: true; artifactsDir: string }> {
81
- const parsed = scriptSchema.parse(script) as Script;
82
-
83
- const scriptName = parsed.name ?? "script";
84
- const artifactsDir = resolveArtifactsDir(parsed, scriptName, options?.artifactsDir);
85
-
86
- mkdirSync(artifactsDir, { recursive: true });
87
-
88
- const sessions = new SessionManager({ snapshotRingSize: 50 });
89
- const launch = parsed.launch;
90
- // Resolve relative paths from the runner's working directory (not the script file).
91
- const cwd = launch.cwd ? resolve(process.cwd(), launch.cwd) : process.cwd();
92
-
93
- const session = sessions.launchSession({
94
- command: launch.command,
95
- args: launch.args ?? [],
96
- cwd,
97
- env: launch.env,
98
- cols: launch.cols,
99
- rows: launch.rows,
100
- name: launch.name,
101
- });
102
-
103
- const snapshots = new Map<string, SnapshotRecord>();
104
- let last: SnapshotRecord | null = null;
105
- let currentStepIndex = -1;
106
- let currentStep: ScriptStep | null = null;
107
-
108
- const resolveGoldenPath = (path: string) =>
109
- isAbsolute(path) ? path : resolve(process.cwd(), path);
110
- const resolveArtifactPath = (path: string) =>
111
- isAbsolute(path) ? path : resolve(artifactsDir, path);
112
-
113
- const trace = parsed.trace ?? {};
114
- const saveCast = trace.saveCast ?? true;
115
- const saveReport = trace.saveReport ?? true;
116
- const castPath = resolveArtifactPath(trace.castPath ?? `${scriptName}.cast`);
117
- const reportPath = resolveArtifactPath(trace.reportPath ?? `${scriptName}.report.html`);
118
-
119
- const stepHandlers = options?.steps;
120
-
121
- // Track each step execution for full report
122
- const executionSteps: {
123
- index: number;
124
- step: ScriptStep;
125
- before: SnapshotRecord | null;
126
- after: SnapshotRecord | null;
127
- durationMs: number;
128
- ok: boolean;
129
- error?: string;
130
- }[] = [];
131
-
132
- try {
133
- for (let stepIndex = 0; stepIndex < parsed.steps.length; stepIndex += 1) {
134
- const step = parsed.steps[stepIndex] as ScriptStep;
135
- currentStepIndex = stepIndex;
136
- currentStep = step;
137
-
138
- const stepStartedAt = Date.now();
139
- // Capture state before step (reuse last if available)
140
- const before = last;
141
-
142
- try {
143
- last = await runStep({
144
- step,
145
- stepIndex,
146
- session,
147
- snapshots,
148
- last,
149
- resolveGoldenPath,
150
- resolveArtifactPath,
151
- updateGoldens: options?.updateGoldens ?? envTruthy(process.env.UPDATE_GOLDENS),
152
- stepHandlers,
153
- artifactsDir,
154
- });
155
-
156
- // Capture snapshot after step if it didn't return a new one
157
- // This ensures we have a visual history for actions, and updates 'last' for subsequent assertions
158
- let after = last;
159
- const stepProducedNewSnapshot = last !== before;
160
-
161
- if (!stepProducedNewSnapshot) {
162
- try {
163
- // We do a lightweight view capture
164
- const captured = await session.snapshotText({
165
- scope: "visible",
166
- trimRight: true,
167
- trimBottom: true,
168
- captureFrame: true,
169
- });
170
- // We construct a record but don't persist it as a named snapshot unless asked
171
- const lines = captured.text.split("\n");
172
- const view = formatSnapshotView({
173
- sessionId: session.id,
174
- scope: "visible",
175
- hash: captured.hash,
176
- lines,
177
- meta: session.getMeta(),
178
- lineNumbers: true,
179
- });
180
- after = { kind: "view", hash: captured.hash, text: view };
181
- last = after; // Update last so next step sees it
182
- } catch {
183
- // Ignore capture errors on best effort
184
- }
185
- }
186
-
187
- executionSteps.push({
188
- index: stepIndex,
189
- step,
190
- before,
191
- after,
192
- durationMs: Date.now() - stepStartedAt,
193
- ok: true,
194
- });
195
- } catch (err) {
196
- // Step failed
197
- executionSteps.push({
198
- index: stepIndex,
199
- step,
200
- before,
201
- after: null, // Will be captured in failure block
202
- durationMs: Date.now() - stepStartedAt,
203
- ok: false,
204
- error: (err as Error).message,
205
- });
206
- throw err;
207
- }
208
- }
209
-
210
- await writeTraceArtifacts({
211
- session,
212
- artifactsDir,
213
- saveCast,
214
- castPath,
215
- saveReport,
216
- reportPath,
217
- reportScope: trace.reportScope,
218
- reportMaxFrames: trace.reportMaxFrames,
219
- scriptName,
220
- result: { ok: true },
221
- executionSteps, // Pass steps to report generator
222
- });
223
- writeTestDataArtifact({
224
- artifactsDir,
225
- scriptName,
226
- ok: true,
227
- executionSteps,
228
- resolveArtifactPath,
229
- });
230
-
231
- sessions.closeAll();
232
- return { ok: true, artifactsDir };
233
- } catch (error) {
234
- try {
235
- writeTestDataArtifact({
236
- artifactsDir,
237
- scriptName,
238
- ok: false,
239
- error: (error as Error).message,
240
- executionSteps,
241
- resolveArtifactPath,
242
- });
243
- await writeFailureArtifacts({
244
- session,
245
- artifactsDir,
246
- scriptName,
247
- stepIndex: currentStepIndex,
248
- step: currentStep,
249
- last,
250
- error,
251
- });
252
- await writeTraceArtifacts({
253
- session,
254
- artifactsDir,
255
- saveCast,
256
- castPath,
257
- saveReport,
258
- reportPath,
259
- reportScope: trace.reportScope,
260
- reportMaxFrames: trace.reportMaxFrames,
261
- scriptName,
262
- result: {
263
- ok: false,
264
- error: (error as Error).message,
265
- failureStep: currentStep
266
- ? { index: currentStepIndex + 1, type: formatStepLabel(currentStep) }
267
- : undefined,
268
- },
269
- executionSteps, // Pass steps so far
270
- });
271
- } catch {
272
- // ignore best-effort artifact writing
273
- } finally {
274
- sessions.closeAll();
275
- }
276
-
277
- throw error;
278
- }
279
- }
280
-
281
- function resolveArtifactsDir(script: Script, scriptName: string, override?: string): string {
282
- if (override?.trim()) return resolve(override.trim());
283
- if (script.artifactsDir?.trim()) return resolve(script.artifactsDir.trim());
284
- return resolve(".tmp", "runs", scriptName);
285
- }
286
-
287
- async function runStep(args: {
288
- step: ScriptStep;
289
- stepIndex: number;
290
- session: ReturnType<SessionManager["launchSession"]>;
291
- snapshots: Map<string, SnapshotRecord>;
292
- last: SnapshotRecord | null;
293
- resolveGoldenPath: (path: string) => string;
294
- resolveArtifactPath: (path: string) => string;
295
- updateGoldens: boolean;
296
- stepHandlers?: Record<string, CustomStepHandler>;
297
- artifactsDir: string;
298
- }): Promise<SnapshotRecord | null> {
299
- const { step } = args;
300
-
301
- try {
302
- if (step.type === "sendText") {
303
- args.session.sendText(step.text, { enter: step.enter });
304
- return args.last;
305
- }
306
-
307
- if (step.type === "pressKey") {
308
- args.session.pressKey(step.key);
309
- return args.last;
310
- }
311
-
312
- if (step.type === "sendMouse") {
313
- const modifiers =
314
- step.shift || step.alt || step.ctrl
315
- ? { shift: step.shift, alt: step.alt, ctrl: step.ctrl }
316
- : undefined;
317
-
318
- args.session.sendMouse({
319
- action: step.action,
320
- x: step.x,
321
- y: step.y,
322
- button: step.button,
323
- modifiers,
324
- });
325
-
326
- return args.last;
327
- }
328
-
329
- if (step.type === "resize") {
330
- args.session.resize(step.cols, step.rows);
331
- return args.last;
332
- }
333
-
334
- if (step.type === "mark") {
335
- args.session.mark(step.label);
336
- return args.last;
337
- }
338
-
339
- if (step.type === "sleep") {
340
- await sleep(step.ms);
341
- return args.last;
342
- }
343
-
344
- if (step.type === "waitForText") {
345
- const regex = step.regex ? new RegExp(step.regex) : undefined;
346
- const result = await args.session.waitForText({
347
- scope: step.scope,
348
- text: step.text,
349
- regex,
350
- timeoutMs: step.timeoutMs ?? 10_000,
351
- intervalMs: step.intervalMs ?? 100,
352
- });
353
- if (!result.found) {
354
- throw new Error(
355
- `step ${args.stepIndex + 1} waitForText not found: ${step.text ?? step.regex ?? ""}`,
356
- );
357
- }
358
- return args.last;
359
- }
360
-
361
- if (step.type === "waitForStableScreen") {
362
- const result = await args.session.waitForStableScreen({
363
- timeoutMs: step.timeoutMs ?? 10_000,
364
- quietMs: step.quietMs ?? 400,
365
- intervalMs: step.intervalMs ?? 80,
366
- });
367
- if (!result.stable) {
368
- throw new Error(`step ${args.stepIndex + 1} waitForStableScreen timed out`);
369
- }
370
- return args.last;
371
- }
372
-
373
- if (step.type === "waitForExit") {
374
- const startedAt = Date.now();
375
- const timeoutMs = step.timeoutMs ?? 10_000;
376
- const intervalMs = step.intervalMs ?? 50;
377
-
378
- while (Date.now() - startedAt <= timeoutMs) {
379
- if (args.session.isClosed()) break;
380
- await sleep(intervalMs);
381
- }
382
-
383
- const reason = args.session.getCloseReason();
384
- if (!reason) {
385
- throw new Error("waitForExit timed out");
386
- }
387
- if (reason.type !== "process_exit") {
388
- throw new Error("waitForExit: session was closed by user");
389
- }
390
- if (step.exitCode !== undefined && reason.exitCode !== step.exitCode) {
391
- throw new Error(`waitForExit: exitCode mismatch (got ${reason.exitCode})`);
392
- }
393
- if (step.signal !== undefined && reason.signal !== step.signal) {
394
- throw new Error(`waitForExit: signal mismatch (got ${String(reason.signal ?? "")})`);
395
- }
396
- return args.last;
397
- }
398
-
399
- if (step.type === "expectMeta") {
400
- await args.session.flush();
401
- const meta = args.session.getMeta();
402
-
403
- if (step.bufferType !== undefined && meta.bufferType !== step.bufferType) {
404
- throw new Error(`expectMeta.bufferType mismatch (got ${meta.bufferType})`);
405
- }
406
- if (step.cols !== undefined && meta.cols !== step.cols) {
407
- throw new Error(`expectMeta.cols mismatch (got ${meta.cols})`);
408
- }
409
- if (step.rows !== undefined && meta.rows !== step.rows) {
410
- throw new Error(`expectMeta.rows mismatch (got ${meta.rows})`);
411
- }
412
-
413
- if (step.cursor) {
414
- const cursorAbsY = meta.baseY + meta.cursorY;
415
- const cursorViewportRow = cursorAbsY - meta.viewportY;
416
- const cursorViewportCol = meta.cursorX;
417
- const actual = { x: cursorViewportCol + 1, y: cursorViewportRow + 1 };
418
- if (actual.x !== step.cursor.x || actual.y !== step.cursor.y) {
419
- throw new Error(`expectMeta.cursor mismatch (got ${actual.x},${actual.y})`);
420
- }
421
- }
422
-
423
- return args.last;
424
- }
425
-
426
- if (step.type === "snapshot") {
427
- const record = await snapshotStep(args.session, step);
428
- persistSnapshotRecord({
429
- record,
430
- saveAs: step.saveAs,
431
- saveTo: step.saveTo,
432
- snapshots: args.snapshots,
433
- resolveArtifactPath: args.resolveArtifactPath,
434
- });
435
- return record;
436
- }
437
-
438
- if (step.type === "expect") {
439
- const record = selectSnapshot(args.last, args.snapshots, step.from);
440
- assertRecordMatches(record, step, args.stepIndex);
441
- return args.last;
442
- }
443
-
444
- if (step.type === "assert") {
445
- const regex = step.regex ? new RegExp(step.regex) : undefined;
446
- const result = await args.session.waitForText({
447
- scope: step.scope,
448
- text: step.text,
449
- regex,
450
- timeoutMs: 0, // assert implies immediate check
451
- intervalMs: 0,
452
- });
453
-
454
- if (!result.found) {
455
- throw new Error(
456
- `step ${args.stepIndex + 1} assert failed: ${step.description || step.text || step.regex || "pattern mismatch"}`,
457
- );
458
- }
459
- return args.last;
460
- }
461
-
462
- if (step.type === "assertSemantic") {
463
- // In this core runner, we don't have LLM access.
464
- // We just log it as a passed step, or perhaps we can trigger a hook?
465
- // For now, let's treat it as a "manual check required" log, but pass execution.
466
- // Or if the user provided a custom handler for it?
467
- // Custom handlers are for "custom" type steps.
468
- // Let's print a warning if we are in a verbose mode?
469
- // Actually, since this is an "assert", passing it blindly is dangerous if it's critical.
470
- // However, without LLM integration here, we can't do much.
471
- // If we want to support it, we could look for an environment variable or callback.
472
- // For this implementation, we will treat it as a "no-op" that always passes,
473
- // assuming the "recording" phase was the verification, OR the user will run this
474
- // with a runner that wraps this and handles assertSemantic?
475
- // But `runStep` is monolithic.
476
- // Let's just log it to stdout if possible, or ignore.
477
- // We'll treat it as OK to allow playback to proceed.
478
- return args.last;
479
- }
480
-
481
- if (step.type === "expectGolden") {
482
- const record = selectSnapshot(args.last, args.snapshots, step.from);
483
- const goldenPath = args.resolveGoldenPath(step.path);
484
- assertGoldenText(goldenPath, `${record.text}\n`, args.updateGoldens);
485
- return args.last;
486
- }
487
-
488
- if (step.type === "custom") {
489
- const handler = args.stepHandlers?.[step.name];
490
- if (!handler) {
491
- throw new Error(`custom handler not found: ${step.name}`);
492
- }
493
-
494
- const ctx: ScriptRunnerContext = {
495
- session: args.session,
496
- stepIndex: args.stepIndex,
497
- last: args.last,
498
- snapshots: args.snapshots,
499
- artifactsDir: args.artifactsDir,
500
- resolveArtifactPath: args.resolveArtifactPath,
501
- resolveGoldenPath: args.resolveGoldenPath,
502
- updateGoldens: args.updateGoldens,
503
- captureSnapshot: async (
504
- snapshotConfig: Omit<Extract<ScriptStep, { type: "snapshot" }>, "type">,
505
- ) => {
506
- const record = await snapshotStep(args.session, {
507
- type: "snapshot",
508
- ...snapshotConfig,
509
- } as Extract<ScriptStep, { type: "snapshot" }>);
510
-
511
- persistSnapshotRecord({
512
- record,
513
- saveAs: snapshotConfig.saveAs,
514
- saveTo: snapshotConfig.saveTo,
515
- snapshots: args.snapshots,
516
- resolveArtifactPath: args.resolveArtifactPath,
517
- });
518
-
519
- return record;
520
- },
521
- getSnapshot: (from?: string) => selectSnapshot(args.last, args.snapshots, from),
522
- writeArtifactText: (path: string, text: string) => {
523
- const resolved = args.resolveArtifactPath(path);
524
- mkdirSync(dirname(resolved), { recursive: true });
525
- writeFileSync(resolved, text, "utf8");
526
- },
527
- assertGoldenText: (path: string, text: string) => {
528
- const goldenPath = args.resolveGoldenPath(path);
529
- assertGoldenText(goldenPath, text, args.updateGoldens);
530
- },
531
- };
532
-
533
- const result = await handler(ctx, step as ScriptCustomStep);
534
- if (!result) return args.last;
535
- return result;
536
- }
537
-
538
- throw new Error(`unknown type: ${(step as ScriptStep).type}`);
539
- } catch (error) {
540
- throw annotateStepError(error, args.stepIndex, step);
541
- }
542
- }
543
-
544
- function persistSnapshotRecord(args: {
545
- record: SnapshotRecord;
546
- saveAs?: string;
547
- saveTo?: string;
548
- snapshots: Map<string, SnapshotRecord>;
549
- resolveArtifactPath: (path: string) => string;
550
- }): void {
551
- const saveAs = args.saveAs?.trim();
552
- if (saveAs) {
553
- args.snapshots.set(saveAs, args.record);
554
- }
555
-
556
- const saveTo = args.saveTo?.trim();
557
- if (!saveTo) return;
558
-
559
- const path = args.resolveArtifactPath(saveTo);
560
- mkdirSync(dirname(path), { recursive: true });
561
- writeFileSync(path, `${args.record.text}\n`, "utf8");
562
- }
563
-
564
- function annotateStepError(error: unknown, stepIndex: number, step: ScriptStep): Error {
565
- const label =
566
- step.type === "custom"
567
- ? `custom(${(step as Extract<ScriptStep, { type: "custom" }>).name})`
568
- : step.type;
569
- const prefix = `step ${stepIndex + 1} ${label}`;
570
-
571
- if (error instanceof Error) {
572
- if (!error.message.startsWith("step ")) {
573
- error.message = `${prefix}: ${error.message}`;
574
- }
575
- return error;
576
- }
577
-
578
- return new Error(`${prefix}: ${String(error)}`);
579
- }
580
-
581
- function selectSnapshot(
582
- last: SnapshotRecord | null,
583
- snapshots: Map<string, SnapshotRecord>,
584
- from?: string,
585
- ): SnapshotRecord {
586
- const key = from?.trim() ? from.trim() : "last";
587
- if (key === "last") {
588
- if (!last) throw new Error("expect: no previous snapshot (from=last)");
589
- return last;
590
- }
591
-
592
- const found = snapshots.get(key);
593
- if (!found) {
594
- throw new Error(`expect: unknown snapshot reference: ${key}`);
595
- }
596
- return found;
597
- }
598
-
599
- function assertRecordMatches(
600
- record: SnapshotRecord,
601
- step: Extract<ScriptStep, { type: "expect" }>,
602
- stepIndex: number,
603
- ): void {
604
- if (step.equals !== undefined && record.text !== step.equals) {
605
- throw new Error(`step ${stepIndex + 1} expect.equals failed`);
606
- }
607
-
608
- if (step.contains && step.contains.length > 0) {
609
- for (const item of step.contains) {
610
- if (!record.text.includes(item)) {
611
- throw new Error(`step ${stepIndex + 1} expect.contains failed: ${JSON.stringify(item)}`);
612
- }
613
- }
614
- }
615
-
616
- if (step.notContains && step.notContains.length > 0) {
617
- for (const item of step.notContains) {
618
- if (record.text.includes(item)) {
619
- throw new Error(`step ${stepIndex + 1} expect.notContains failed: ${JSON.stringify(item)}`);
620
- }
621
- }
622
- }
623
-
624
- if (step.regex) {
625
- const regex = new RegExp(step.regex);
626
- if (!regex.test(record.text)) {
627
- throw new Error(`step ${stepIndex + 1} expect.regex failed: ${JSON.stringify(step.regex)}`);
628
- }
629
- }
630
- }
631
-
632
- function assertGoldenText(path: string, text: string, update: boolean): void {
633
- if (update) {
634
- mkdirSync(dirname(path), { recursive: true });
635
- writeFileSync(path, text, "utf8");
636
- return;
637
- }
638
-
639
- const expected = readFileSync(path, "utf8");
640
- if (text !== expected) {
641
- throw new Error(`golden mismatch: ${path}`);
642
- }
643
- }
644
-
645
- async function writeFailureArtifacts(args: {
646
- session: ReturnType<SessionManager["launchSession"]>;
647
- artifactsDir: string;
648
- scriptName: string;
649
- stepIndex: number;
650
- step: ScriptStep | null;
651
- last: SnapshotRecord | null;
652
- error: unknown;
653
- }): Promise<void> {
654
- const { session, artifactsDir, scriptName, stepIndex, step, last, error } = args;
655
-
656
- const err = error instanceof Error ? error : new Error(String(error));
657
- const errorText = err.stack ?? err.message;
658
- writeFileSync(join(artifactsDir, "failure.error.txt"), `${errorText}\n`, "utf8");
659
-
660
- const stepPayload = {
661
- script: scriptName,
662
- stepIndex: stepIndex >= 0 ? stepIndex + 1 : null,
663
- step: step ?? null,
664
- last: last ? { kind: last.kind, hash: last.hash } : null,
665
- };
666
- writeFileSync(
667
- join(artifactsDir, "failure.step.json"),
668
- `${JSON.stringify(stepPayload, null, 2)}\n`,
669
- "utf8",
670
- );
671
-
672
- let capturedText: string | undefined = undefined;
673
- let capturedHash: string | undefined = undefined;
674
- try {
675
- const captured = await session.snapshotText({
676
- scope: "visible",
677
- trimRight: true,
678
- trimBottom: true,
679
- captureFrame: true,
680
- });
681
- capturedText = captured.text;
682
- capturedHash = captured.hash;
683
- } catch {
684
- // ignore best-effort snapshot on failure
685
- }
686
-
687
- const text = capturedText ?? last?.text;
688
- const hash = capturedHash ?? last?.hash ?? "unknown";
689
-
690
- if (text !== undefined) {
691
- writeFileSync(join(artifactsDir, "failure.last.txt"), `${text}\n`, "utf8");
692
-
693
- const view = formatSnapshotView({
694
- sessionId: session.id,
695
- scope: "visible",
696
- hash,
697
- lines: text.split("\n"),
698
- meta: session.getMeta(),
699
- lineNumbers: true,
700
- });
701
- writeFileSync(join(artifactsDir, "failure.last.view.txt"), `${view}\n`, "utf8");
702
- }
703
- }
704
-
705
- async function snapshotStep(
706
- session: ReturnType<SessionManager["launchSession"]>,
707
- step: Extract<ScriptStep, { type: "snapshot" }>,
708
- ): Promise<SnapshotRecord> {
709
- if (step.kind === "grid") {
710
- if (step.mask && step.mask.length > 0) {
711
- throw new Error("snapshot.kind=grid does not support mask (use text/view instead)");
712
- }
713
- const { grid, hash } = await session.snapshotGrid({
714
- trimRight: step.trimRight,
715
- includeStyles: step.includeStyles,
716
- captureFrame: true,
717
- });
718
- return { kind: step.kind, hash, text: JSON.stringify(grid, null, 2) };
719
- }
720
-
721
- if (step.kind === "ansi" || step.kind === "view_ansi") {
722
- const { ansi, hash } = await session.snapshotAnsi({
723
- scope: step.scope,
724
- trimRight: step.trimRight,
725
- trimBottom: step.trimBottom ?? true,
726
- maxLines: step.maxLines,
727
- tailLines: step.tailLines,
728
- mask: step.mask as TextMaskRule[] | undefined,
729
- });
730
-
731
- if (step.kind === "ansi") {
732
- return { kind: step.kind, hash, text: ansi };
733
- }
734
-
735
- const lines = ansi.split("\n");
736
- const view = formatSnapshotView({
737
- sessionId: session.id,
738
- scope: step.scope ?? "visible",
739
- hash,
740
- lines,
741
- meta: session.getMeta(),
742
- lineNumbers: step.lineNumbers,
743
- });
744
- return { kind: step.kind, hash, text: view };
745
- }
746
-
747
- const { text, hash } = await session.snapshotText({
748
- scope: step.scope,
749
- trimRight: step.trimRight,
750
- trimBottom: step.trimBottom ?? true,
751
- maxLines: step.maxLines,
752
- tailLines: step.tailLines,
753
- captureFrame: true,
754
- mask: step.mask as TextMaskRule[] | undefined,
755
- });
756
-
757
- if (step.kind === "text") {
758
- return { kind: step.kind, hash, text };
759
- }
760
-
761
- const lines = text.split("\n");
762
- const view = formatSnapshotView({
763
- sessionId: session.id,
764
- scope: step.scope ?? "visible",
765
- hash,
766
- lines,
767
- meta: session.getMeta(),
768
- lineNumbers: step.lineNumbers,
769
- });
770
- return { kind: step.kind, hash, text: view };
771
- }
772
-
773
- async function writeTraceArtifacts(args: {
774
- session: ReturnType<SessionManager["launchSession"]>;
775
- artifactsDir: string;
776
- saveCast: boolean;
777
- castPath: string;
778
- saveReport: boolean;
779
- reportPath: string;
780
- reportScope?: "visible" | "buffer";
781
- reportMaxFrames?: number;
782
- scriptName?: string;
783
- result?: TraceReportResult;
784
- executionSteps?: unknown[]; // Type hack to avoid import cycle or complex type movement
785
- }): Promise<void> {
786
- if (!args.saveCast && !args.saveReport) return;
787
-
788
- const snapshot = await args.session.snapshotCast();
789
-
790
- if (args.saveCast) {
791
- mkdirSync(dirname(args.castPath), { recursive: true });
792
- writeFileSync(args.castPath, snapshot.cast, "utf8");
793
- }
794
-
795
- if (args.saveReport) {
796
- const artifactHrefs = buildReportArtifactHrefs({
797
- reportPath: args.reportPath,
798
- castPath: args.saveCast ? args.castPath : null,
799
- artifactsDir: args.artifactsDir,
800
- includeFailures: args.result?.ok === false,
801
- });
802
-
803
- const html = await generateTraceReportHtml(snapshot.cast, {
804
- scope: args.reportScope,
805
- maxFrames: args.reportMaxFrames,
806
- scriptName: args.scriptName,
807
- result: args.result,
808
- artifacts: artifactHrefs,
809
- steps: args.executionSteps,
810
- });
811
- mkdirSync(dirname(args.reportPath), { recursive: true });
812
- writeFileSync(args.reportPath, html, "utf8");
813
- ensureAsciinemaPlayerAssets(args.reportPath);
814
- }
815
- }
816
-
817
- function buildReportArtifactHrefs(args: {
818
- reportPath: string;
819
- castPath: string | null;
820
- artifactsDir: string;
821
- includeFailures: boolean;
822
- }): TraceReportArtifacts | undefined {
823
- const items: TraceReportArtifacts = {};
824
-
825
- if (args.castPath) {
826
- items.castHref = relativeHref(args.reportPath, args.castPath);
827
- }
828
-
829
- if (args.includeFailures) {
830
- items.failureErrorHref = relativeHref(
831
- args.reportPath,
832
- join(args.artifactsDir, "failure.error.txt"),
833
- );
834
- items.failureStepHref = relativeHref(
835
- args.reportPath,
836
- join(args.artifactsDir, "failure.step.json"),
837
- );
838
- items.failureLastTextHref = relativeHref(
839
- args.reportPath,
840
- join(args.artifactsDir, "failure.last.txt"),
841
- );
842
- items.failureLastViewHref = relativeHref(
843
- args.reportPath,
844
- join(args.artifactsDir, "failure.last.view.txt"),
845
- );
846
- }
847
-
848
- return Object.keys(items).length ? items : undefined;
849
- }
850
-
851
- function relativeHref(fromFile: string, toFile: string): string {
852
- const rel = relative(dirname(fromFile), toFile);
853
- const normalized = rel.replace(/\\/g, "/");
854
- return normalized.startsWith(".") ? normalized : `./${normalized}`;
855
- }
856
-
857
- function formatStepLabel(step: ScriptStep): string {
858
- return step.type === "custom"
859
- ? `custom(${(step as ScriptCustomStep).name})`
860
- : (step as ScriptStep).type;
861
- }
862
-
863
- function formatPublicStepLabel(step: ScriptStep): string {
864
- const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
865
- if (step.type === "custom") return `custom(${(step as ScriptCustomStep).name})`;
866
-
867
- if (step.type === "sendText") {
868
- const enter = step.enter !== undefined ? ` enter=${String(step.enter)}` : "";
869
- if (!showText) return `sendText <redacted> (len=${step.text.length}${enter})`;
870
- return `sendText "${truncateInline(step.text)}"${enter ? ` (${enter.trim()})` : ""}`;
871
- }
872
-
873
- if (step.type === "pressKey") return `pressKey ${step.key}`;
874
- if (step.type === "sendMouse") return `sendMouse ${step.action} (${step.x},${step.y})`;
875
- if (step.type === "resize") return `resize ${step.cols}x${step.rows}`;
876
- if (step.type === "mark") return step.label ? `mark ${step.label}` : "mark";
877
- if (step.type === "sleep") return `sleep ${step.ms}ms`;
878
-
879
- if (step.type === "waitForText") {
880
- if (!showText)
881
- return step.text ? "waitForText (text)" : step.regex ? "waitForText (regex)" : "waitForText";
882
- if (step.text) return `waitFor "${truncateInline(step.text)}"`;
883
- if (step.regex) return `waitFor /${truncateInline(step.regex)}/`;
884
- return "waitForText";
885
- }
886
-
887
- if (step.type === "waitForStableScreen") return "waitForStableScreen";
888
- if (step.type === "waitForExit") return "waitForExit";
889
- if (step.type === "expectMeta") return "expectMeta";
890
-
891
- if (step.type === "snapshot") {
892
- return `snapshot ${step.kind}${step.saveAs ? ` as ${step.saveAs}` : ""}`;
893
- }
894
-
895
- if (step.type === "expect") {
896
- const parts: string[] = [];
897
- if (step.equals !== undefined) parts.push("equals");
898
- if (step.contains?.length) parts.push(`contains(${step.contains.length})`);
899
- if (step.notContains?.length) parts.push(`notContains(${step.notContains.length})`);
900
- if (step.regex) parts.push("regex");
901
- return parts.length ? `expect ${parts.join(",")}` : "expect";
902
- }
903
-
904
- if (step.type === "expectGolden") return `expectGolden ${step.path}`;
905
-
906
- if (step.type === "assert") {
907
- if (!showText) return step.text ? "assert (text)" : step.regex ? "assert (regex)" : "assert";
908
- if (step.text) return `assert "${truncateInline(step.text)}"`;
909
- if (step.regex) return `assert /${truncateInline(step.regex)}/`;
910
- if (step.description) return `assert "${truncateInline(step.description)}"`;
911
- return "assert";
912
- }
913
-
914
- if (step.type === "assertSemantic") return "assertSemantic";
915
-
916
- return assertUnreachableStep(step);
917
- }
918
-
919
- function truncateInline(text: string, maxChars: number = 60): string {
920
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
921
- if (normalized.length <= maxChars) return normalized;
922
- return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
923
- }
924
-
925
- function assertUnreachableStep(_step: never): string {
926
- return "unknown";
927
- }
928
-
929
- function writeTestDataArtifact(args: {
930
- artifactsDir: string;
931
- scriptName: string;
932
- ok: boolean;
933
- error?: string;
934
- executionSteps: Array<{
935
- index: number;
936
- step: ScriptStep;
937
- durationMs: number;
938
- ok: boolean;
939
- error?: string;
940
- }>;
941
- resolveArtifactPath: (path: string) => string;
942
- }): void {
943
- try {
944
- const testId = basename(args.artifactsDir);
945
- const outPath = args.resolveArtifactPath("test.data.js");
946
- mkdirSync(dirname(outPath), { recursive: true });
947
-
948
- const steps = args.executionSteps.map((s) => ({
949
- index: s.index + 1,
950
- type: s.step.type,
951
- label: formatPublicStepLabel(s.step),
952
- ok: s.ok,
953
- durationMs: s.durationMs,
954
- error: s.ok ? null : (s.error ?? null),
955
- }));
956
-
957
- const data = {
958
- version: 1,
959
- testId,
960
- scriptName: args.scriptName,
961
- ok: args.ok,
962
- error: args.ok ? null : (args.error ?? null),
963
- stepCount: steps.length,
964
- steps,
965
- generatedAt: new Date().toISOString(),
966
- };
967
-
968
- const json = JSON.stringify(data).replaceAll("<", "\\u003c");
969
- const key = JSON.stringify(testId);
970
- const js =
971
- `globalThis.__ptywright = globalThis.__ptywright || {};\n` +
972
- `globalThis.__ptywright.tests = globalThis.__ptywright.tests || {};\n` +
973
- `globalThis.__ptywright.tests[${key}] = ${json};\n`;
974
- writeFileSync(outPath, js, "utf8");
975
- } catch {
976
- // best-effort
977
- }
978
- }
979
-
980
- function envTruthy(value: string | undefined): boolean {
981
- const v = value?.trim().toLowerCase();
982
- return v === "1" || v === "true" || v === "yes" || v === "on";
983
- }