ptywright 0.3.0 → 0.5.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.
@@ -1,11 +1,12 @@
1
- import { a as applyTextMaskRules, c as encodeSgrMouse, d as isDefaultStyle, f as styleKey, i as fnv1a32, l as extractStyle, n as TraceRecorder, o as snapshotGrid, p as encodeKey, r as sleep, s as snapshotLines, t as TerminalSession, u as findMeaningfulEndCol } from "./terminal_session-DopC7Xg6.mjs";
1
+ import { n as mergeProcessEnv, o as relativeHref, t as envTruthy } from "./env-DPYHo-zH.mjs";
2
+ import { a as fnv1a32, c as extractStyle, d as styleKey, f as encodeSgrMouse, i as TraceRecorder, l as findMeaningfulEndCol, n as sleep, o as snapshotGrid, p as encodeKey, r as applyTextMaskRules, s as snapshotLines, t as TerminalSession, u as isDefaultStyle } from "./terminal_session-DJKr-O3X.mjs";
2
3
  import { createRequire } from "node:module";
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
3
7
  import { z } from "zod";
4
8
  import { spawn } from "bun-pty";
5
9
  import { Terminal } from "@xterm/headless";
6
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
- import { pathToFileURL } from "node:url";
8
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
10
  //#region src/pty/bun_pty_adapter.ts
10
11
  function toForkOptions(options) {
11
12
  return {
@@ -176,7 +177,7 @@ var SessionManager = class {
176
177
  const rows = clampInt$2(args.rows ?? 24, 1, 300);
177
178
  const cwd = args.cwd ?? process.cwd();
178
179
  const term = args.env?.TERM ?? args.name ?? "xterm-256color";
179
- const env = mergeEnv({
180
+ const env = mergeProcessEnv({
180
181
  TERM: term,
181
182
  COLORTERM: "truecolor"
182
183
  }, args.env);
@@ -226,13 +227,6 @@ function clampInt$2(value, min, max) {
226
227
  if (int > max) return max;
227
228
  return int;
228
229
  }
229
- function mergeEnv(base, override) {
230
- const env = {};
231
- for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
232
- for (const [k, v] of Object.entries(base)) env[k] = v;
233
- if (override) for (const [k, v] of Object.entries(override)) env[k] = v;
234
- return env;
235
- }
236
230
  function pickTraceEnv(env) {
237
231
  const picked = {};
238
232
  for (const key of [
@@ -247,6 +241,232 @@ function pickTraceEnv(env) {
247
241
  return picked;
248
242
  }
249
243
  //#endregion
244
+ //#region src/script/text_mask_schema.ts
245
+ const textMaskRuleSchema = z.object({
246
+ regex: z.string().min(1),
247
+ flags: z.string().optional(),
248
+ replacement: z.string().optional(),
249
+ preserveLength: z.boolean().optional()
250
+ });
251
+ const assertionScriptStepSchemas = [
252
+ z.object({
253
+ type: z.literal("expectMeta"),
254
+ bufferType: z.enum(["normal", "alternate"]).optional(),
255
+ cols: z.number().int().positive().optional(),
256
+ rows: z.number().int().positive().optional(),
257
+ cursor: z.object({
258
+ x: z.number().int().positive(),
259
+ y: z.number().int().positive()
260
+ }).optional()
261
+ }).superRefine((value, ctx) => {
262
+ if (value.bufferType === void 0 && value.cols === void 0 && value.rows === void 0 && value.cursor === void 0) ctx.addIssue({
263
+ code: z.ZodIssueCode.custom,
264
+ message: "expectMeta requires at least one assertion (bufferType/cols/rows/cursor)"
265
+ });
266
+ }),
267
+ z.object({
268
+ type: z.literal("snapshot"),
269
+ kind: z.enum([
270
+ "text",
271
+ "view",
272
+ "ansi",
273
+ "view_ansi",
274
+ "grid"
275
+ ]),
276
+ scope: z.enum(["visible", "buffer"]).optional(),
277
+ trimRight: z.boolean().optional(),
278
+ trimBottom: z.boolean().optional(),
279
+ maxLines: z.number().int().positive().optional(),
280
+ tailLines: z.number().int().positive().optional(),
281
+ lineNumbers: z.boolean().optional(),
282
+ includeStyles: z.boolean().optional(),
283
+ mask: z.array(textMaskRuleSchema).optional(),
284
+ saveAs: z.string().optional(),
285
+ saveTo: z.string().optional()
286
+ }).superRefine((value, ctx) => {
287
+ if (value.maxLines !== void 0 && value.tailLines !== void 0) ctx.addIssue({
288
+ code: z.ZodIssueCode.custom,
289
+ message: "snapshot: maxLines and tailLines are mutually exclusive"
290
+ });
291
+ }),
292
+ z.object({
293
+ type: z.literal("expect"),
294
+ from: z.string().optional(),
295
+ equals: z.string().optional(),
296
+ contains: z.array(z.string()).optional(),
297
+ notContains: z.array(z.string()).optional(),
298
+ regex: z.string().optional()
299
+ }).superRefine((value, ctx) => {
300
+ if (value.equals === void 0 && !value.contains?.length && !value.notContains?.length && !value.regex) ctx.addIssue({
301
+ code: z.ZodIssueCode.custom,
302
+ message: "expect requires at least one matcher (equals/contains/notContains/regex)"
303
+ });
304
+ }),
305
+ z.object({
306
+ type: z.literal("expectGolden"),
307
+ from: z.string().optional(),
308
+ path: z.string().min(1)
309
+ }),
310
+ z.object({
311
+ type: z.literal("custom"),
312
+ name: z.string().min(1),
313
+ payload: z.unknown().optional()
314
+ }),
315
+ z.object({
316
+ type: z.literal("assert"),
317
+ scope: z.enum(["visible", "buffer"]).optional(),
318
+ text: z.string().optional(),
319
+ regex: z.string().optional(),
320
+ description: z.string().optional()
321
+ }).superRefine((value, ctx) => {
322
+ if (!value.text && !value.regex) ctx.addIssue({
323
+ code: z.ZodIssueCode.custom,
324
+ message: "assert requires text or regex"
325
+ });
326
+ }),
327
+ z.object({
328
+ type: z.literal("assertSemantic"),
329
+ prompt: z.string().min(1),
330
+ description: z.string().optional()
331
+ })
332
+ ];
333
+ const inputScriptStepSchemas = [
334
+ z.object({
335
+ type: z.literal("sendText"),
336
+ text: z.string(),
337
+ enter: z.boolean().optional()
338
+ }),
339
+ z.object({
340
+ type: z.literal("pressKey"),
341
+ key: z.string().min(1)
342
+ }),
343
+ z.object({
344
+ type: z.literal("sendMouse"),
345
+ action: z.enum([
346
+ "down",
347
+ "up",
348
+ "move",
349
+ "click",
350
+ "scroll_up",
351
+ "scroll_down"
352
+ ]),
353
+ x: z.number().int(),
354
+ y: z.number().int(),
355
+ button: z.enum([
356
+ "left",
357
+ "middle",
358
+ "right"
359
+ ]).optional(),
360
+ shift: z.boolean().optional(),
361
+ alt: z.boolean().optional(),
362
+ ctrl: z.boolean().optional()
363
+ }),
364
+ z.object({
365
+ type: z.literal("resize"),
366
+ cols: z.number().int().positive(),
367
+ rows: z.number().int().positive()
368
+ }),
369
+ z.object({
370
+ type: z.literal("mark"),
371
+ label: z.string().optional()
372
+ }),
373
+ z.object({
374
+ type: z.literal("sleep"),
375
+ ms: z.number().int().nonnegative()
376
+ })
377
+ ];
378
+ const waitScriptStepSchemas = [
379
+ z.object({
380
+ type: z.literal("waitForText"),
381
+ scope: z.enum(["visible", "buffer"]).optional(),
382
+ text: z.string().optional(),
383
+ regex: z.string().optional(),
384
+ timeoutMs: z.number().int().positive().optional(),
385
+ intervalMs: z.number().int().positive().optional()
386
+ }).superRefine((value, ctx) => {
387
+ if (!value.text && !value.regex) ctx.addIssue({
388
+ code: z.ZodIssueCode.custom,
389
+ message: "waitForText requires text or regex"
390
+ });
391
+ }),
392
+ z.object({
393
+ type: z.literal("waitForStableScreen"),
394
+ timeoutMs: z.number().int().positive().optional(),
395
+ quietMs: z.number().int().positive().optional(),
396
+ intervalMs: z.number().int().positive().optional()
397
+ }),
398
+ z.object({
399
+ type: z.literal("waitForExit"),
400
+ timeoutMs: z.number().int().positive().optional(),
401
+ intervalMs: z.number().int().positive().optional(),
402
+ exitCode: z.number().int().optional(),
403
+ signal: z.union([z.number().int(), z.string()]).optional()
404
+ })
405
+ ];
406
+ //#endregion
407
+ //#region src/script/step_schemas.ts
408
+ const scriptStepSchema = z.union([
409
+ ...inputScriptStepSchemas,
410
+ ...waitScriptStepSchemas,
411
+ ...assertionScriptStepSchemas
412
+ ]);
413
+ //#endregion
414
+ //#region src/script/schema.ts
415
+ const launchConfigSchema = z.object({
416
+ backend: z.enum([
417
+ "pty",
418
+ "frames",
419
+ "ink",
420
+ "ratatui"
421
+ ]).optional(),
422
+ command: z.string().min(1).optional(),
423
+ args: z.array(z.string()).optional(),
424
+ cwd: z.string().optional(),
425
+ env: z.record(z.string()).optional(),
426
+ cols: z.number().int().positive().optional(),
427
+ rows: z.number().int().positive().optional(),
428
+ name: z.string().optional(),
429
+ frame: z.string().optional(),
430
+ frames: z.array(z.union([z.string(), z.object({
431
+ name: z.string().optional(),
432
+ text: z.string().optional(),
433
+ frame: z.string().optional(),
434
+ snapshot: z.string().optional(),
435
+ lastFrame: z.string().optional()
436
+ })])).optional(),
437
+ framePath: z.string().optional(),
438
+ frameModule: z.string().optional(),
439
+ advanceOnInput: z.boolean().optional()
440
+ }).superRefine((value, ctx) => {
441
+ if ((value.backend ?? "pty") === "pty") {
442
+ if (!value.command) ctx.addIssue({
443
+ code: z.ZodIssueCode.custom,
444
+ path: ["command"],
445
+ message: "launch.command is required when backend=pty"
446
+ });
447
+ return;
448
+ }
449
+ if (!value.frame && !value.frames?.length && !value.framePath && !value.frameModule) ctx.addIssue({
450
+ code: z.ZodIssueCode.custom,
451
+ message: "framework launch requires one of frame, frames, framePath, or frameModule when backend is not pty"
452
+ });
453
+ });
454
+ const scriptTraceSchema = z.object({
455
+ saveCast: z.boolean().optional(),
456
+ saveReport: z.boolean().optional(),
457
+ castPath: z.string().optional(),
458
+ reportPath: z.string().optional(),
459
+ reportScope: z.enum(["visible", "buffer"]).optional(),
460
+ reportMaxFrames: z.number().int().positive().optional()
461
+ });
462
+ const scriptSchema = z.object({
463
+ name: z.string().optional(),
464
+ artifactsDir: z.string().optional(),
465
+ launch: launchConfigSchema,
466
+ trace: scriptTraceSchema.optional(),
467
+ steps: z.array(scriptStepSchema).min(1)
468
+ });
469
+ //#endregion
250
470
  //#region src/terminal/view.ts
251
471
  function formatSnapshotView(options) {
252
472
  const lineNumbers = options.lineNumbers ?? true;
@@ -307,234 +527,227 @@ function ensureAsciinemaPlayerAssets(reportPath) {
307
527
  }
308
528
  }
309
529
  //#endregion
310
- //#region src/trace/report.ts
311
- async function generateTraceReportHtml(cast, options) {
312
- const parsed = parseAsciicast(cast);
313
- const termInfo = getTermInfo(parsed.header);
314
- const terminal = new Terminal({
315
- cols: termInfo.cols,
316
- rows: termInfo.rows,
317
- allowProposedApi: true,
318
- scrollback: 2e3,
319
- convertEol: true
320
- });
321
- const scope = options?.scope ?? "visible";
322
- const maxFrames = options?.maxFrames ?? 200;
323
- const scriptName = options?.scriptName?.trim() ? options.scriptName.trim() : "";
324
- const result = options?.result;
325
- const artifacts = options?.artifacts;
326
- const steps = options?.steps;
327
- let writeChain = Promise.resolve();
328
- const frames = [];
329
- let previousRowSignatures = null;
330
- const capture = (args) => {
331
- if (frames.length >= maxFrames) return;
332
- let viewHtml;
333
- let changedCount;
334
- if (args.overrideViewText) {
335
- const parsedView = parseSnapshotViewText(args.overrideViewText.text);
336
- const headerLine = parsedView.headerLine ?? (args.overrideViewText.hash?.trim() ? `hash=${args.overrideViewText.hash.trim()}` : "snapshot");
337
- const rowSignatures = parsedView.rows.map((r) => r.text);
338
- const changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
339
- previousRowSignatures = rowSignatures;
340
- changedCount = changedLines.size;
341
- viewHtml = renderSnapshotViewTextHtml({
342
- headerLine,
343
- rows: parsedView.rows,
344
- changedLines
345
- });
346
- } else {
347
- let lines;
348
- let hash;
349
- let changedLines = /* @__PURE__ */ new Set();
350
- if (scope === "visible") {
351
- const grid = snapshotGrid(terminal, {
352
- trimRight: true,
353
- includeStyles: true
354
- });
355
- lines = grid.lines;
356
- hash = fnv1a32(JSON.stringify(grid));
357
- const rowSignatures = lines.map((line, idx) => {
358
- const runs = grid.styleRuns?.[idx] ?? [];
359
- if (line === "" && runs.length === 0) return "";
360
- return `${line}\n${JSON.stringify(runs)}`;
361
- });
362
- changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
363
- previousRowSignatures = rowSignatures;
364
- } else {
365
- lines = snapshotLines(terminal, {
366
- scope,
367
- trimRight: true
368
- });
369
- hash = fnv1a32(lines.join("\n"));
370
- }
371
- changedCount = changedLines.size;
372
- viewHtml = renderSnapshotViewHtml({
373
- terminal,
374
- sessionId: "replay",
375
- scope,
376
- hash,
377
- lines,
378
- meta: getMeta(terminal),
379
- lineNumbers: true,
380
- changedLines,
381
- trimRight: true
382
- });
383
- }
384
- const previousFrame = frames.at(-1);
385
- frames.push({
386
- id: `frame-${frames.length + 1}`,
387
- atSeconds: args.atSeconds,
388
- kind: args.kind,
389
- label: args.label,
390
- markLabel: args.markLabel,
391
- viewHtml,
392
- changedCount,
393
- stepInfo: args.stepInfo,
394
- previousViewHtml: previousFrame?.viewHtml
395
- });
396
- };
397
- if (steps && steps.length > 0) for (let i = 0; i < steps.length; i += 1) {
398
- const stepRec = steps[i];
399
- if (!stepRec) continue;
400
- const stepLabel = formatStepLabel$1(stepRec.step);
401
- const viewText = stepRec.after?.text ?? "";
402
- const displayIndex = (typeof stepRec.index === "number" ? stepRec.index : i) + 1;
403
- const stepType = stepRec.step.type;
404
- const kind = stepType === "mark" ? "mark" : stepType === "resize" ? "resize" : "step";
405
- const markLabel = kind === "mark" && typeof stepRec.step.label === "string" ? String(stepRec.step.label) : void 0;
406
- const stepParams = {};
407
- for (const [key, value] of Object.entries(stepRec.step)) if (key !== "type") stepParams[key] = value;
408
- capture({
409
- atSeconds: displayIndex,
410
- kind,
411
- label: stepLabel,
412
- markLabel,
413
- stepInfo: {
414
- index: displayIndex,
415
- type: stepType,
416
- ok: stepRec.ok,
417
- error: stepRec.error,
418
- params: Object.keys(stepParams).length > 0 ? stepParams : void 0,
419
- durationMs: typeof stepRec.durationMs === "number" ? stepRec.durationMs : void 0
420
- },
421
- overrideViewText: {
422
- text: viewText,
423
- hash: stepRec.after?.hash
424
- }
425
- });
426
- if (frames.length >= maxFrames) break;
427
- }
428
- else {
429
- for (const event of parsed.events) {
430
- const [time, type, data] = event;
431
- if (type === "o") writeChain = writeChain.then(() => writeTerminal(terminal, data));
432
- else if (type === "r") writeChain.then(() => {
433
- const resized = parseResize(data);
434
- if (resized) terminal.resize(resized.cols, resized.rows);
435
- capture({
436
- atSeconds: time,
437
- kind: "resize",
438
- label: `resize ${data}`
439
- });
440
- });
441
- else if (type === "m") writeChain.then(() => {
442
- const markLabel = (data ?? "").trim();
443
- capture({
444
- atSeconds: time,
445
- kind: "mark",
446
- label: markLabel ? `mark ${markLabel}` : "mark",
447
- markLabel
448
- });
449
- });
450
- }
451
- await writeChain;
452
- capture({
453
- atSeconds: parsed.events.at(-1)?.[0] ?? 0,
454
- kind: "final",
455
- label: "final"
456
- });
530
+ //#region src/trace/asciicast_parse.ts
531
+ function parseAsciicast(cast) {
532
+ const lines = cast.trimEnd().split("\n");
533
+ const header = safeJsonObject(lines[0]);
534
+ const events = [];
535
+ for (const line of lines.slice(1)) {
536
+ if (!line.trim()) continue;
537
+ const value = JSON.parse(line);
538
+ if (!Array.isArray(value) || value.length < 3) continue;
539
+ const time = Number(value[0]);
540
+ const type = String(value[1]);
541
+ const data = String(value[2]);
542
+ if (!Number.isFinite(time)) continue;
543
+ if (type === "o" || type === "i" || type === "r" || type === "m") events.push([
544
+ time,
545
+ type,
546
+ data
547
+ ]);
457
548
  }
458
- terminal.dispose();
459
- return renderHtml({
460
- cast,
461
- header: parsed.header,
462
- term: termInfo,
463
- scope,
464
- scriptName,
465
- result,
466
- artifacts,
467
- frames,
468
- eventCount: parsed.events.length
469
- });
549
+ return {
550
+ header,
551
+ events
552
+ };
470
553
  }
471
- function formatStepLabel$1(step) {
472
- const showText = envTruthy$1(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
473
- if (step.type === "custom" && typeof step.name === "string") return `custom(${step.name})`;
474
- if (step.type === "sendText") {
475
- const enter = typeof step.enter === "boolean" ? step.enter : void 0;
476
- const enterSuffix = enter !== void 0 ? `enter=${enter}` : "";
477
- const description = typeof step.description === "string" ? step.description : "";
478
- const text = typeof step.text === "string" ? step.text : description;
479
- if (!text) return enterSuffix ? `sendText (${enterSuffix})` : "sendText";
480
- if (!showText) return `sendText <redacted> (len=${text.length}${enterSuffix ? `, ${enterSuffix}` : ""})`;
481
- return `sendText "${truncateInline$1(text)}"${enterSuffix ? ` (${enterSuffix})` : ""}`;
482
- }
483
- if (step.type === "waitForText") {
484
- const text = typeof step.text === "string" ? step.text : void 0;
485
- const regex = typeof step.regex === "string" ? step.regex : void 0;
486
- const description = typeof step.description === "string" ? step.description : void 0;
487
- if (!showText) {
488
- if (text) return "waitForText (text)";
489
- if (regex) return "waitForText (regex)";
490
- return "waitForText";
491
- }
492
- if (text) return `waitFor "${truncateInline$1(text)}"`;
493
- if (regex) return `waitFor /${truncateInline$1(regex)}/`;
494
- if (description) return `waitForText "${truncateInline$1(description)}"`;
495
- return "waitForText";
496
- }
497
- if (step.type === "assert") {
498
- const text = typeof step.text === "string" ? step.text : void 0;
499
- const regex = typeof step.regex === "string" ? step.regex : void 0;
500
- const description = typeof step.description === "string" ? step.description : void 0;
501
- if (!showText) {
502
- if (text) return "assert (text)";
503
- if (regex) return "assert (regex)";
504
- return "assert";
505
- }
506
- if (text) return `assert "${truncateInline$1(text)}"`;
507
- if (regex) return `assert /${truncateInline$1(regex)}/`;
508
- if (description) return `assert "${truncateInline$1(description)}"`;
509
- return "assert";
510
- }
511
- if (step.type === "pressKey" && typeof step.key === "string") return `pressKey ${step.key}`;
512
- if (step.type === "mark") {
513
- const label = typeof step.label === "string" ? step.label.trim() : "";
514
- return label ? `mark ${label}` : "mark";
515
- }
516
- if (step.type === "resize") {
517
- const cols = typeof step.cols === "number" ? step.cols : void 0;
518
- const rows = typeof step.rows === "number" ? step.rows : void 0;
519
- if (cols !== void 0 && rows !== void 0) return `resize ${cols}x${rows}`;
520
- return "resize";
554
+ function safeJsonObject(line) {
555
+ if (!line) return {};
556
+ try {
557
+ const value = JSON.parse(line);
558
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
559
+ } catch {
560
+ return {};
521
561
  }
522
- return step.type;
523
562
  }
524
- function truncateInline$1(text, maxChars = 60) {
525
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
526
- if (normalized.length <= maxChars) return normalized;
527
- return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
563
+ //#endregion
564
+ //#region src/trace/report_cli.ts
565
+ async function runTraceReportCli(argv, generateTraceReportHtml) {
566
+ const inputPath = argv[0];
567
+ if (!inputPath) {
568
+ console.error("Usage: bun run src/trace/report.ts <path/to/cast>");
569
+ process.exit(2);
570
+ }
571
+ const html = await generateTraceReportHtml(await Bun.file(inputPath).text());
572
+ const outPath = join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}.report.html`);
573
+ writeFileSync(outPath, html);
574
+ ensureAsciinemaPlayerAssets(outPath);
575
+ console.log(outPath);
528
576
  }
529
- function envTruthy$1(value) {
530
- if (!value?.trim()) return false;
531
- const normalized = value.trim().toLowerCase();
532
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
577
+ //#endregion
578
+ //#region src/common/html.ts
579
+ function escapeHtml(text) {
580
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
533
581
  }
534
- function stripAnsi(str) {
535
- return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
582
+ function jsonForHtml(data) {
583
+ return JSON.stringify(data).replaceAll("<", "\\u003c");
536
584
  }
537
- function parseSnapshotViewText(viewText) {
585
+ function coerceDisplayString(value) {
586
+ if (value === null || value === void 0) return "";
587
+ if (typeof value === "string") return value;
588
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
589
+ try {
590
+ return JSON.stringify(value) ?? "";
591
+ } catch {
592
+ return "";
593
+ }
594
+ }
595
+ //#endregion
596
+ //#region src/trace/term_info.ts
597
+ function getTermInfo(header) {
598
+ if (Number(header.version ?? 2) === 3) {
599
+ const term = header.term;
600
+ return {
601
+ cols: clampInt$1(Number(term?.cols ?? 80), 1, 500),
602
+ rows: clampInt$1(Number(term?.rows ?? 24), 1, 300),
603
+ type: typeof term?.type === "string" ? term.type : "xterm-256color"
604
+ };
605
+ }
606
+ return {
607
+ cols: clampInt$1(Number(header.width ?? 80), 1, 500),
608
+ rows: clampInt$1(Number(header.height ?? 24), 1, 300),
609
+ type: typeof header.term === "string" ? header.term : "xterm-256color"
610
+ };
611
+ }
612
+ function parseResize(value) {
613
+ const match = /^\s*(\d+)x(\d+)\s*$/.exec(value);
614
+ if (!match) return null;
615
+ return {
616
+ cols: clampInt$1(Number(match[1] ?? 0), 1, 500),
617
+ rows: clampInt$1(Number(match[2] ?? 0), 1, 300)
618
+ };
619
+ }
620
+ function clampInt$1(value, min, max) {
621
+ if (!Number.isFinite(value)) return min;
622
+ const int = Math.trunc(value);
623
+ if (int < min) return min;
624
+ if (int > max) return max;
625
+ return int;
626
+ }
627
+ //#endregion
628
+ //#region src/trace/report_snapshot_styles.ts
629
+ function renderVisibleRowHtml(terminal, rowIndex, trimRight) {
630
+ const buffer = terminal.buffer.active;
631
+ const nullCell = buffer.getNullCell();
632
+ const startY = buffer.viewportY;
633
+ const line = buffer.getLine(startY + rowIndex);
634
+ const endCol = trimRight ? findMeaningfulEndCol(line, terminal.cols, nullCell) : terminal.cols;
635
+ const segments = [];
636
+ let currentKey = null;
637
+ let currentStyle = null;
638
+ let currentText = "";
639
+ const flush = () => {
640
+ if (!currentStyle) return;
641
+ if (currentText.length === 0) return;
642
+ segments.push({
643
+ key: currentKey ?? styleKey(currentStyle),
644
+ style: currentStyle,
645
+ text: currentText
646
+ });
647
+ currentText = "";
648
+ };
649
+ for (let x = 0; x < endCol; x += 1) {
650
+ const cell = line?.getCell(x, nullCell);
651
+ if (!cell) {
652
+ if (currentStyle) {
653
+ flush();
654
+ currentStyle = null;
655
+ currentKey = null;
656
+ }
657
+ continue;
658
+ }
659
+ if (cell.getWidth() === 0) continue;
660
+ const chars = cell.getChars() || " ";
661
+ const style = extractStyle(cell);
662
+ const key = styleKey(style);
663
+ if (!currentStyle) {
664
+ currentStyle = style;
665
+ currentKey = key;
666
+ currentText = chars;
667
+ continue;
668
+ }
669
+ if (key === currentKey) {
670
+ currentText += chars;
671
+ continue;
672
+ }
673
+ flush();
674
+ currentStyle = style;
675
+ currentKey = key;
676
+ currentText = chars;
677
+ }
678
+ if (currentStyle) flush();
679
+ return segments.map((segment) => renderSegmentHtml(segment.text, segment.style)).join("");
680
+ }
681
+ function renderSegmentHtml(text, style) {
682
+ const safeText = escapeHtml(text);
683
+ if (isDefaultStyle(style)) return safeText;
684
+ const css = styleToCss(style);
685
+ if (!css) return `<span class="seg">${safeText}</span>`;
686
+ return `<span class="seg" style="${css}">${safeText}</span>`;
687
+ }
688
+ function styleToCss(style) {
689
+ let fg = colorToCss(style.fg);
690
+ let bg = colorToCss(style.bg);
691
+ if (style.inverse) {
692
+ const tmp = fg;
693
+ fg = bg;
694
+ bg = tmp;
695
+ }
696
+ const decls = [];
697
+ if (fg) decls.push(`color: ${fg}`);
698
+ if (bg) decls.push(`background-color: ${bg}`);
699
+ if (style.bold) decls.push("font-weight: 600");
700
+ if (style.italic) decls.push("font-style: italic");
701
+ if (style.dim) decls.push("opacity: 0.75");
702
+ const decorations = [];
703
+ if (style.underline) decorations.push("underline");
704
+ if (style.strikethrough) decorations.push("line-through");
705
+ if (decorations.length > 0) decls.push(`text-decoration: ${decorations.join(" ")}`);
706
+ return decls.join("; ");
707
+ }
708
+ function colorToCss(color) {
709
+ if (color.mode === "default") return null;
710
+ if (color.mode === "rgb") return `#${(color.value & 16777215).toString(16).padStart(6, "0")}`;
711
+ return xterm256Color(clampInt$1(color.value, 0, 255));
712
+ }
713
+ function xterm256Color(idx) {
714
+ const table16 = [
715
+ "#000000",
716
+ "#800000",
717
+ "#008000",
718
+ "#808000",
719
+ "#000080",
720
+ "#800080",
721
+ "#008080",
722
+ "#c0c0c0",
723
+ "#808080",
724
+ "#ff0000",
725
+ "#00ff00",
726
+ "#ffff00",
727
+ "#0000ff",
728
+ "#ff00ff",
729
+ "#00ffff",
730
+ "#ffffff"
731
+ ];
732
+ if (idx < 16) return table16[idx] ?? "#000000";
733
+ if (idx >= 16 && idx <= 231) {
734
+ const c = [
735
+ 0,
736
+ 95,
737
+ 135,
738
+ 175,
739
+ 215,
740
+ 255
741
+ ];
742
+ const n = idx - 16;
743
+ return `rgb(${c[Math.trunc(n / 36) % 6] ?? 0} ${c[Math.trunc(n / 6) % 6] ?? 0} ${c[n % 6] ?? 0})`;
744
+ }
745
+ const v = clampInt$1(8 + (idx - 232) * 10, 0, 255);
746
+ return `rgb(${v} ${v} ${v})`;
747
+ }
748
+ //#endregion
749
+ //#region src/trace/report_snapshot_view.ts
750
+ function parseSnapshotViewText(viewText) {
538
751
  const lines = stripAnsi(viewText).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
539
752
  const first = lines[0] ?? "";
540
753
  const hasHeader = /\bsession=/.test(first) && /\bhash=/.test(first);
@@ -561,75 +774,371 @@ function renderSnapshotViewTextHtml(options) {
561
774
  }
562
775
  return out.join("");
563
776
  }
564
- async function writeTerminal(terminal, data) {
565
- await new Promise((resolve) => {
566
- terminal.write(data, resolve);
777
+ function renderSnapshotViewHtml(options) {
778
+ const headerLine = formatHeaderLine({
779
+ sessionId: options.sessionId,
780
+ scope: options.scope,
781
+ hash: options.hash,
782
+ meta: options.meta,
783
+ changedCount: options.changedLines.size
567
784
  });
785
+ const digits = Math.max(2, String(options.lines.length).length);
786
+ const out = [`<span class="headerblock">${escapeHtml(headerLine)}</span>`];
787
+ if (options.scope === "visible") {
788
+ for (let i = 0; i < options.lines.length; i += 1) {
789
+ const n = i + 1;
790
+ const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
791
+ const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
792
+ const contentHtml = renderVisibleRowHtml(options.terminal, i, options.trimRight);
793
+ const rowClass = options.changedLines.has(i) ? "row changed" : "row";
794
+ out.push(`<span class="${rowClass}">${prefixHtml}${contentHtml}</span>`);
795
+ }
796
+ return out.join("");
797
+ }
798
+ for (let i = 0; i < options.lines.length; i += 1) {
799
+ const n = i + 1;
800
+ const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
801
+ const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
802
+ out.push(`<span class="row">${prefixHtml}${escapeHtml(options.lines[i] ?? "")}</span>`);
803
+ }
804
+ return out.join("");
568
805
  }
569
- function renderHtml(input) {
570
- const title = input.scriptName || coerceDisplayString(input.header.title) || "ptywright trace report";
571
- const command = coerceDisplayString(input.header.command);
572
- const timestamp = input.header.timestamp;
573
- const headerJson = JSON.stringify(input.header, null, 2);
574
- const durationSeconds = input.frames.at(-1)?.atSeconds ?? 0;
575
- const markFrames = input.frames.filter((f) => f.kind === "mark");
576
- const resultLabel = input.result?.ok === true ? "PASS" : input.result?.ok === false ? "FAIL" : "UNKNOWN";
577
- const resultClass = input.result?.ok === true ? "pass" : input.result?.ok === false ? "fail" : "unknown";
578
- const artifactsRows = [];
579
- if (input.artifacts?.castHref?.trim()) artifactsRows.push({
580
- label: "cast",
581
- href: input.artifacts.castHref.trim()
582
- });
583
- if (input.artifacts?.failureErrorHref?.trim()) artifactsRows.push({
584
- label: "failure.error.txt",
585
- href: input.artifacts.failureErrorHref.trim()
586
- });
587
- if (input.artifacts?.failureStepHref?.trim()) artifactsRows.push({
588
- label: "failure.step.json",
589
- href: input.artifacts.failureStepHref.trim()
590
- });
591
- if (input.artifacts?.failureLastTextHref?.trim()) artifactsRows.push({
592
- label: "failure.last.txt",
593
- href: input.artifacts.failureLastTextHref.trim()
594
- });
595
- if (input.artifacts?.failureLastViewHref?.trim()) artifactsRows.push({
596
- label: "failure.last.view.txt",
597
- href: input.artifacts.failureLastViewHref.trim()
598
- });
599
- const artifactsHtml = artifactsRows.length === 0 ? `<p class="muted">No artifacts linked.</p>` : `<ul class="artifacts">
600
- ${artifactsRows.map((a) => `<li><a href="${escapeHtml(a.href)}">${escapeHtml(a.label)}</a><span class="muted"> (${escapeHtml(a.href)})</span></li>`).join("\n")}
601
- </ul>`;
602
- const castPlayerHtml = `
603
- <p class="muted">Render the full recording using <span class="mono">asciinema-player</span>.</p>
604
- <div class="cast-controls">
605
- <button id="castToggleSize" class="badge chip" type="button">expand</button>
606
- <span id="castPlayerStatus" class="muted mono"></span>
607
- </div>
608
- <div id="castPlayer" class="cast-player"></div>
609
- <script id="castData" type="application/json">${jsonForHtml(input.cast)}<\/script>
610
- <script>
611
- (function () {
612
- const statusEl = document.getElementById("castPlayerStatus");
613
- const container = document.getElementById("castPlayer");
614
- const castEl = document.getElementById("castData");
615
- const toggleBtn = document.getElementById("castToggleSize");
616
- if (!container || !castEl) return;
617
-
618
- // Load external assets automatically (no extra click).
619
- const VERSION = "3.9.0";
620
- const LOCAL_CSS = "./asciinema-player.css";
621
- const LOCAL_JS = "./asciinema-player.min.js";
622
- // Use multiple CDNs to avoid regional blocks (e.g. jsdelivr).
623
- const CDN_BASES = [
624
- "https://cdn.jsdelivr.net/npm/asciinema-player@" + VERSION + "/dist/bundle/",
625
- "https://unpkg.com/asciinema-player@" + VERSION + "/dist/bundle/",
626
- ];
627
- const CSS_URLS = [LOCAL_CSS, ...CDN_BASES.map((b) => b + "asciinema-player.css")];
628
- const JS_URLS = [LOCAL_JS, ...CDN_BASES.map((b) => b + "asciinema-player.min.js")];
629
-
630
- function setStatus(text) {
631
- if (statusEl) statusEl.textContent = text ? " " + text : "";
632
- }
806
+ function diffLineIndices(previous, next) {
807
+ const out = /* @__PURE__ */ new Set();
808
+ const max = Math.max(previous.length, next.length);
809
+ for (let i = 0; i < max; i += 1) if ((previous[i] ?? "") !== (next[i] ?? "")) out.add(i);
810
+ return out;
811
+ }
812
+ function getTerminalMeta(terminal) {
813
+ const buffer = terminal.buffer.active;
814
+ return {
815
+ cols: terminal.cols,
816
+ rows: terminal.rows,
817
+ bufferType: buffer.type,
818
+ viewportY: buffer.viewportY,
819
+ baseY: buffer.baseY,
820
+ length: buffer.length,
821
+ cursorX: buffer.cursorX,
822
+ cursorY: buffer.cursorY
823
+ };
824
+ }
825
+ function stripAnsi(str) {
826
+ return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
827
+ }
828
+ function formatHeaderLine(input) {
829
+ const cursorViewportRow = input.meta.baseY + input.meta.cursorY - input.meta.viewportY;
830
+ const cursorViewportCol = input.meta.cursorX;
831
+ return [
832
+ `session=${input.sessionId}`,
833
+ `scope=${input.scope}`,
834
+ `size=${input.meta.cols}x${input.meta.rows}`,
835
+ `buffer=${input.meta.bufferType}`,
836
+ `cursor=${cursorViewportCol + 1},${cursorViewportRow + 1}`,
837
+ `hash=${input.hash}`,
838
+ `changed=${input.changedCount}`
839
+ ].join(" ");
840
+ }
841
+ //#endregion
842
+ //#region src/trace/report_frame_capture.ts
843
+ function createFrameCapture(args) {
844
+ let previousRowSignatures = null;
845
+ return (captureArgs) => {
846
+ if (args.frames.length >= args.maxFrames) return;
847
+ const view = captureArgs.overrideViewText ? renderOverrideView(captureArgs.overrideViewText, previousRowSignatures) : renderTerminalView(args.terminal, args.scope, previousRowSignatures);
848
+ previousRowSignatures = view.rowSignatures;
849
+ const previousFrame = args.frames.at(-1);
850
+ args.frames.push({
851
+ id: `frame-${args.frames.length + 1}`,
852
+ atSeconds: captureArgs.atSeconds,
853
+ kind: captureArgs.kind,
854
+ label: captureArgs.label,
855
+ markLabel: captureArgs.markLabel,
856
+ viewHtml: view.viewHtml,
857
+ changedCount: view.changedCount,
858
+ stepInfo: captureArgs.stepInfo,
859
+ previousViewHtml: previousFrame?.viewHtml
860
+ });
861
+ };
862
+ }
863
+ function renderOverrideView(overrideViewText, previousRowSignatures) {
864
+ const parsedView = parseSnapshotViewText(overrideViewText.text);
865
+ const headerLine = parsedView.headerLine ?? (overrideViewText.hash?.trim() ? `hash=${overrideViewText.hash.trim()}` : "snapshot");
866
+ const rowSignatures = parsedView.rows.map((row) => row.text);
867
+ const changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
868
+ return {
869
+ rowSignatures,
870
+ changedCount: changedLines.size,
871
+ viewHtml: renderSnapshotViewTextHtml({
872
+ headerLine,
873
+ rows: parsedView.rows,
874
+ changedLines
875
+ })
876
+ };
877
+ }
878
+ function renderTerminalView(terminal, scope, previousRowSignatures) {
879
+ let lines;
880
+ let hash;
881
+ let changedLines = /* @__PURE__ */ new Set();
882
+ let rowSignatures;
883
+ if (scope === "visible") {
884
+ const grid = snapshotGrid(terminal, {
885
+ trimRight: true,
886
+ includeStyles: true
887
+ });
888
+ lines = grid.lines;
889
+ hash = fnv1a32(JSON.stringify(grid));
890
+ rowSignatures = lines.map((line, idx) => {
891
+ const runs = grid.styleRuns?.[idx] ?? [];
892
+ if (line === "" && runs.length === 0) return "";
893
+ return `${line}\n${JSON.stringify(runs)}`;
894
+ });
895
+ changedLines = diffLineIndices(previousRowSignatures ?? [], rowSignatures);
896
+ } else {
897
+ lines = snapshotLines(terminal, {
898
+ scope,
899
+ trimRight: true
900
+ });
901
+ hash = fnv1a32(lines.join("\n"));
902
+ rowSignatures = previousRowSignatures ?? [];
903
+ }
904
+ return {
905
+ rowSignatures,
906
+ changedCount: changedLines.size,
907
+ viewHtml: renderSnapshotViewHtml({
908
+ terminal,
909
+ sessionId: "replay",
910
+ scope,
911
+ hash,
912
+ lines,
913
+ meta: getTerminalMeta(terminal),
914
+ lineNumbers: true,
915
+ changedLines,
916
+ trimRight: true
917
+ })
918
+ };
919
+ }
920
+ //#endregion
921
+ //#region src/trace/report_step_label.ts
922
+ function formatStepLabel$1(step) {
923
+ const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
924
+ if (step.type === "custom" && typeof step.name === "string") return `custom(${step.name})`;
925
+ if (step.type === "sendText") {
926
+ const enter = typeof step.enter === "boolean" ? step.enter : void 0;
927
+ const enterSuffix = enter !== void 0 ? `enter=${enter}` : "";
928
+ const description = typeof step.description === "string" ? step.description : "";
929
+ const text = typeof step.text === "string" ? step.text : description;
930
+ if (!text) return enterSuffix ? `sendText (${enterSuffix})` : "sendText";
931
+ if (!showText) return `sendText <redacted> (len=${text.length}${enterSuffix ? `, ${enterSuffix}` : ""})`;
932
+ return `sendText "${truncateInline$1(text)}"${enterSuffix ? ` (${enterSuffix})` : ""}`;
933
+ }
934
+ if (step.type === "waitForText") {
935
+ const text = typeof step.text === "string" ? step.text : void 0;
936
+ const regex = typeof step.regex === "string" ? step.regex : void 0;
937
+ const description = typeof step.description === "string" ? step.description : void 0;
938
+ if (!showText) {
939
+ if (text) return "waitForText (text)";
940
+ if (regex) return "waitForText (regex)";
941
+ return "waitForText";
942
+ }
943
+ if (text) return `waitFor "${truncateInline$1(text)}"`;
944
+ if (regex) return `waitFor /${truncateInline$1(regex)}/`;
945
+ if (description) return `waitForText "${truncateInline$1(description)}"`;
946
+ return "waitForText";
947
+ }
948
+ if (step.type === "assert") {
949
+ const text = typeof step.text === "string" ? step.text : void 0;
950
+ const regex = typeof step.regex === "string" ? step.regex : void 0;
951
+ const description = typeof step.description === "string" ? step.description : void 0;
952
+ if (!showText) {
953
+ if (text) return "assert (text)";
954
+ if (regex) return "assert (regex)";
955
+ return "assert";
956
+ }
957
+ if (text) return `assert "${truncateInline$1(text)}"`;
958
+ if (regex) return `assert /${truncateInline$1(regex)}/`;
959
+ if (description) return `assert "${truncateInline$1(description)}"`;
960
+ return "assert";
961
+ }
962
+ if (step.type === "pressKey" && typeof step.key === "string") return `pressKey ${step.key}`;
963
+ if (step.type === "mark") {
964
+ const label = typeof step.label === "string" ? step.label.trim() : "";
965
+ return label ? `mark ${label}` : "mark";
966
+ }
967
+ if (step.type === "resize") {
968
+ const cols = typeof step.cols === "number" ? step.cols : void 0;
969
+ const rows = typeof step.rows === "number" ? step.rows : void 0;
970
+ if (cols !== void 0 && rows !== void 0) return `resize ${cols}x${rows}`;
971
+ return "resize";
972
+ }
973
+ return step.type;
974
+ }
975
+ function truncateInline$1(text, maxChars = 60) {
976
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
977
+ if (normalized.length <= maxChars) return normalized;
978
+ return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
979
+ }
980
+ //#endregion
981
+ //#region src/trace/report_frames.ts
982
+ async function buildTraceReportFrames(args) {
983
+ const terminal = new Terminal({
984
+ cols: args.term.cols,
985
+ rows: args.term.rows,
986
+ allowProposedApi: true,
987
+ scrollback: 2e3,
988
+ convertEol: true
989
+ });
990
+ const frames = [];
991
+ const capture = createFrameCapture({
992
+ terminal,
993
+ frames,
994
+ scope: args.scope,
995
+ maxFrames: args.maxFrames
996
+ });
997
+ const steps = args.steps;
998
+ if (steps && steps.length > 0) buildStepFrames(steps, frames, args.maxFrames, capture);
999
+ else await buildCastEventFrames(args.parsed, terminal, capture);
1000
+ terminal.dispose();
1001
+ return frames;
1002
+ }
1003
+ function buildStepFrames(steps, frames, maxFrames, capture) {
1004
+ for (let i = 0; i < steps.length; i += 1) {
1005
+ const stepRec = steps[i];
1006
+ if (!stepRec) continue;
1007
+ const stepLabel = formatStepLabel$1(stepRec.step);
1008
+ const viewText = stepRec.after?.text ?? "";
1009
+ const displayIndex = (typeof stepRec.index === "number" ? stepRec.index : i) + 1;
1010
+ const stepType = stepRec.step.type;
1011
+ const kind = stepType === "mark" ? "mark" : stepType === "resize" ? "resize" : "step";
1012
+ const markLabel = kind === "mark" && typeof stepRec.step.label === "string" ? String(stepRec.step.label) : void 0;
1013
+ const stepParams = collectStepParams(stepRec.step);
1014
+ capture({
1015
+ atSeconds: displayIndex,
1016
+ kind,
1017
+ label: stepLabel,
1018
+ markLabel,
1019
+ stepInfo: {
1020
+ index: displayIndex,
1021
+ type: stepType,
1022
+ ok: stepRec.ok,
1023
+ error: stepRec.error,
1024
+ params: Object.keys(stepParams).length > 0 ? stepParams : void 0,
1025
+ durationMs: typeof stepRec.durationMs === "number" ? stepRec.durationMs : void 0
1026
+ },
1027
+ overrideViewText: {
1028
+ text: viewText,
1029
+ hash: stepRec.after?.hash
1030
+ }
1031
+ });
1032
+ if (frames.length >= maxFrames) break;
1033
+ }
1034
+ }
1035
+ function collectStepParams(step) {
1036
+ const stepParams = {};
1037
+ for (const [key, value] of Object.entries(step)) if (key !== "type") stepParams[key] = value;
1038
+ return stepParams;
1039
+ }
1040
+ async function buildCastEventFrames(parsed, terminal, capture) {
1041
+ let writeChain = Promise.resolve();
1042
+ for (const event of parsed.events) {
1043
+ const [time, type, data] = event;
1044
+ if (type === "o") writeChain = writeChain.then(() => writeTerminal(terminal, data));
1045
+ else if (type === "r") writeChain.then(() => {
1046
+ const resized = parseResize(data);
1047
+ if (resized) terminal.resize(resized.cols, resized.rows);
1048
+ capture({
1049
+ atSeconds: time,
1050
+ kind: "resize",
1051
+ label: `resize ${data}`
1052
+ });
1053
+ });
1054
+ else if (type === "m") writeChain.then(() => {
1055
+ const markLabel = (data ?? "").trim();
1056
+ capture({
1057
+ atSeconds: time,
1058
+ kind: "mark",
1059
+ label: markLabel ? `mark ${markLabel}` : "mark",
1060
+ markLabel
1061
+ });
1062
+ });
1063
+ }
1064
+ await writeChain;
1065
+ capture({
1066
+ atSeconds: parsed.events.at(-1)?.[0] ?? 0,
1067
+ kind: "final",
1068
+ label: "final"
1069
+ });
1070
+ }
1071
+ async function writeTerminal(terminal, data) {
1072
+ await new Promise((resolve) => {
1073
+ terminal.write(data, resolve);
1074
+ });
1075
+ }
1076
+ //#endregion
1077
+ //#region src/trace/report_html.ts
1078
+ function renderTraceReportHtml(input) {
1079
+ const title = input.scriptName || coerceDisplayString(input.header.title) || "ptywright trace report";
1080
+ const command = coerceDisplayString(input.header.command);
1081
+ const timestamp = input.header.timestamp;
1082
+ const headerJson = JSON.stringify(input.header, null, 2);
1083
+ const durationSeconds = input.frames.at(-1)?.atSeconds ?? 0;
1084
+ const markFrames = input.frames.filter((f) => f.kind === "mark");
1085
+ const resultLabel = input.result?.ok === true ? "PASS" : input.result?.ok === false ? "FAIL" : "UNKNOWN";
1086
+ const resultClass = input.result?.ok === true ? "pass" : input.result?.ok === false ? "fail" : "unknown";
1087
+ const artifactsRows = [];
1088
+ if (input.artifacts?.castHref?.trim()) artifactsRows.push({
1089
+ label: "cast",
1090
+ href: input.artifacts.castHref.trim()
1091
+ });
1092
+ if (input.artifacts?.failureErrorHref?.trim()) artifactsRows.push({
1093
+ label: "failure.error.txt",
1094
+ href: input.artifacts.failureErrorHref.trim()
1095
+ });
1096
+ if (input.artifacts?.failureStepHref?.trim()) artifactsRows.push({
1097
+ label: "failure.step.json",
1098
+ href: input.artifacts.failureStepHref.trim()
1099
+ });
1100
+ if (input.artifacts?.failureLastTextHref?.trim()) artifactsRows.push({
1101
+ label: "failure.last.txt",
1102
+ href: input.artifacts.failureLastTextHref.trim()
1103
+ });
1104
+ if (input.artifacts?.failureLastViewHref?.trim()) artifactsRows.push({
1105
+ label: "failure.last.view.txt",
1106
+ href: input.artifacts.failureLastViewHref.trim()
1107
+ });
1108
+ const artifactsHtml = artifactsRows.length === 0 ? `<p class="muted">No artifacts linked.</p>` : `<ul class="artifacts">
1109
+ ${artifactsRows.map((a) => `<li><a href="${escapeHtml(a.href)}">${escapeHtml(a.label)}</a><span class="muted"> (${escapeHtml(a.href)})</span></li>`).join("\n")}
1110
+ </ul>`;
1111
+ const castPlayerHtml = `
1112
+ <p class="muted">Render the full recording using <span class="mono">asciinema-player</span>.</p>
1113
+ <div class="cast-controls">
1114
+ <button id="castToggleSize" class="badge chip" type="button">expand</button>
1115
+ <span id="castPlayerStatus" class="muted mono"></span>
1116
+ </div>
1117
+ <div id="castPlayer" class="cast-player"></div>
1118
+ <script id="castData" type="application/json">${jsonForHtml(input.cast)}<\/script>
1119
+ <script>
1120
+ (function () {
1121
+ const statusEl = document.getElementById("castPlayerStatus");
1122
+ const container = document.getElementById("castPlayer");
1123
+ const castEl = document.getElementById("castData");
1124
+ const toggleBtn = document.getElementById("castToggleSize");
1125
+ if (!container || !castEl) return;
1126
+
1127
+ // Load external assets automatically (no extra click).
1128
+ const VERSION = "3.9.0";
1129
+ const LOCAL_CSS = "./asciinema-player.css";
1130
+ const LOCAL_JS = "./asciinema-player.min.js";
1131
+ // Use multiple CDNs to avoid regional blocks (e.g. jsdelivr).
1132
+ const CDN_BASES = [
1133
+ "https://cdn.jsdelivr.net/npm/asciinema-player@" + VERSION + "/dist/bundle/",
1134
+ "https://unpkg.com/asciinema-player@" + VERSION + "/dist/bundle/",
1135
+ ];
1136
+ const CSS_URLS = [LOCAL_CSS, ...CDN_BASES.map((b) => b + "asciinema-player.css")];
1137
+ const JS_URLS = [LOCAL_JS, ...CDN_BASES.map((b) => b + "asciinema-player.min.js")];
1138
+
1139
+ function setStatus(text) {
1140
+ if (statusEl) statusEl.textContent = text ? " " + text : "";
1141
+ }
633
1142
 
634
1143
  async function loadCssOnce() {
635
1144
  if (document.getElementById("asciinemaPlayerCss")) return;
@@ -1803,275 +2312,387 @@ timestamp=${escapeHtml(coerceDisplayString(timestamp))}</div>
1803
2312
  </body>
1804
2313
  </html>`;
1805
2314
  }
1806
- function jsonForHtml(data) {
1807
- return JSON.stringify(data).replaceAll("<", "\\u003c");
1808
- }
1809
- function renderSnapshotViewHtml(options) {
1810
- const headerLine = formatHeaderLine({
1811
- sessionId: options.sessionId,
1812
- scope: options.scope,
1813
- hash: options.hash,
1814
- meta: options.meta,
1815
- changedCount: options.changedLines.size
2315
+ //#endregion
2316
+ //#region src/trace/report.ts
2317
+ async function generateTraceReportHtml(cast, options) {
2318
+ const parsed = parseAsciicast(cast);
2319
+ const termInfo = getTermInfo(parsed.header);
2320
+ const scope = options?.scope ?? "visible";
2321
+ const maxFrames = options?.maxFrames ?? 200;
2322
+ const scriptName = options?.scriptName?.trim() ? options.scriptName.trim() : "";
2323
+ const result = options?.result;
2324
+ const artifacts = options?.artifacts;
2325
+ const frames = await buildTraceReportFrames({
2326
+ parsed,
2327
+ term: termInfo,
2328
+ scope,
2329
+ maxFrames,
2330
+ steps: options?.steps
2331
+ });
2332
+ return renderTraceReportHtml({
2333
+ cast,
2334
+ header: parsed.header,
2335
+ term: termInfo,
2336
+ scope,
2337
+ scriptName,
2338
+ result,
2339
+ artifacts,
2340
+ frames,
2341
+ eventCount: parsed.events.length
1816
2342
  });
1817
- const digits = Math.max(2, String(options.lines.length).length);
1818
- const out = [`<span class="headerblock">${escapeHtml(headerLine)}</span>`];
1819
- if (options.scope === "visible") {
1820
- for (let i = 0; i < options.lines.length; i += 1) {
1821
- const n = i + 1;
1822
- const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
1823
- const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
1824
- const contentHtml = renderVisibleRowHtml(options.terminal, i, options.trimRight);
1825
- const rowClass = options.changedLines.has(i) ? "row changed" : "row";
1826
- out.push(`<span class="${rowClass}">${prefixHtml}${contentHtml}</span>`);
1827
- }
1828
- return out.join("");
1829
- }
1830
- for (let i = 0; i < options.lines.length; i += 1) {
1831
- const n = i + 1;
1832
- const prefix = options.lineNumbers ? `${String(n).padStart(digits, "0")}│ ` : "";
1833
- const prefixHtml = options.lineNumbers ? `<span class="ln">${escapeHtml(prefix)}</span>` : "";
1834
- out.push(`<span class="row">${prefixHtml}${escapeHtml(options.lines[i] ?? "")}</span>`);
1835
- }
1836
- return out.join("");
1837
2343
  }
1838
- function renderVisibleRowHtml(terminal, rowIndex, trimRight) {
1839
- const buffer = terminal.buffer.active;
1840
- const nullCell = buffer.getNullCell();
1841
- const startY = buffer.viewportY;
1842
- const line = buffer.getLine(startY + rowIndex);
1843
- const endCol = trimRight ? findMeaningfulEndCol(line, terminal.cols, nullCell) : terminal.cols;
1844
- const segments = [];
1845
- let currentKey = null;
1846
- let currentStyle = null;
1847
- let currentText = "";
1848
- const flush = () => {
1849
- if (!currentStyle) return;
1850
- if (currentText.length === 0) return;
1851
- segments.push({
1852
- key: currentKey ?? styleKey(currentStyle),
1853
- style: currentStyle,
1854
- text: currentText
1855
- });
1856
- currentText = "";
2344
+ if (import.meta.main) await runTraceReportCli(process.argv.slice(2), generateTraceReportHtml);
2345
+ //#endregion
2346
+ //#region src/script/failure_artifacts.ts
2347
+ async function writeFailureArtifacts(args) {
2348
+ const { session, artifactsDir, scriptName, stepIndex, step, last, error } = args;
2349
+ const err = error instanceof Error ? error : new Error(String(error));
2350
+ const errorText = err.stack ?? err.message;
2351
+ writeFileSync(join(artifactsDir, "failure.error.txt"), `${errorText}\n`, "utf8");
2352
+ const stepPayload = {
2353
+ script: scriptName,
2354
+ stepIndex: stepIndex >= 0 ? stepIndex + 1 : null,
2355
+ step: step ?? null,
2356
+ last: last ? {
2357
+ kind: last.kind,
2358
+ hash: last.hash
2359
+ } : null
1857
2360
  };
1858
- for (let x = 0; x < endCol; x += 1) {
1859
- const cell = line?.getCell(x, nullCell);
1860
- if (!cell) {
1861
- if (currentStyle) {
1862
- flush();
1863
- currentStyle = null;
1864
- currentKey = null;
1865
- }
1866
- continue;
1867
- }
1868
- if (cell.getWidth() === 0) continue;
1869
- const chars = cell.getChars() || " ";
1870
- const style = extractStyle(cell);
1871
- const key = styleKey(style);
1872
- if (!currentStyle) {
1873
- currentStyle = style;
1874
- currentKey = key;
1875
- currentText = chars;
1876
- continue;
1877
- }
1878
- if (key === currentKey) {
1879
- currentText += chars;
1880
- continue;
1881
- }
1882
- flush();
1883
- currentStyle = style;
1884
- currentKey = key;
1885
- currentText = chars;
2361
+ writeFileSync(join(artifactsDir, "failure.step.json"), `${JSON.stringify(stepPayload, null, 2)}\n`, "utf8");
2362
+ let capturedText = void 0;
2363
+ let capturedHash = void 0;
2364
+ try {
2365
+ const captured = await session.snapshotText({
2366
+ scope: "visible",
2367
+ trimRight: true,
2368
+ trimBottom: true,
2369
+ captureFrame: true
2370
+ });
2371
+ capturedText = captured.text;
2372
+ capturedHash = captured.hash;
2373
+ } catch {}
2374
+ const text = capturedText ?? last?.text;
2375
+ const hash = capturedHash ?? last?.hash ?? "unknown";
2376
+ if (text !== void 0) {
2377
+ writeFileSync(join(artifactsDir, "failure.last.txt"), `${text}\n`, "utf8");
2378
+ const view = formatSnapshotView({
2379
+ sessionId: session.id,
2380
+ scope: "visible",
2381
+ hash,
2382
+ lines: text.split("\n"),
2383
+ meta: session.getMeta(),
2384
+ lineNumbers: true
2385
+ });
2386
+ writeFileSync(join(artifactsDir, "failure.last.view.txt"), `${view}\n`, "utf8");
1886
2387
  }
1887
- if (currentStyle) flush();
1888
- return segments.map((segment) => renderSegmentHtml(segment.text, segment.style)).join("");
1889
2388
  }
1890
- function renderSegmentHtml(text, style) {
1891
- const safeText = escapeHtml(text);
1892
- if (isDefaultStyle(style)) return safeText;
1893
- const css = styleToCss(style);
1894
- if (!css) return `<span class="seg">${safeText}</span>`;
1895
- return `<span class="seg" style="${css}">${safeText}</span>`;
2389
+ //#endregion
2390
+ //#region src/script/step_label.ts
2391
+ function formatStepLabel(step) {
2392
+ return step.type === "custom" ? `custom(${step.name})` : step.type;
1896
2393
  }
1897
- function styleToCss(style) {
1898
- let fg = colorToCss(style.fg);
1899
- let bg = colorToCss(style.bg);
1900
- if (style.inverse) {
1901
- const tmp = fg;
1902
- fg = bg;
1903
- bg = tmp;
2394
+ function formatPublicStepLabel(step) {
2395
+ const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
2396
+ if (step.type === "custom") return `custom(${step.name})`;
2397
+ if (step.type === "sendText") {
2398
+ const enter = step.enter !== void 0 ? ` enter=${String(step.enter)}` : "";
2399
+ if (!showText) return `sendText <redacted> (len=${step.text.length}${enter})`;
2400
+ return `sendText "${truncateInline(step.text)}"${enter ? ` (${enter.trim()})` : ""}`;
1904
2401
  }
1905
- const decls = [];
1906
- if (fg) decls.push(`color: ${fg}`);
1907
- if (bg) decls.push(`background-color: ${bg}`);
1908
- if (style.bold) decls.push("font-weight: 600");
1909
- if (style.italic) decls.push("font-style: italic");
1910
- if (style.dim) decls.push("opacity: 0.75");
1911
- const decorations = [];
1912
- if (style.underline) decorations.push("underline");
1913
- if (style.strikethrough) decorations.push("line-through");
1914
- if (decorations.length > 0) decls.push(`text-decoration: ${decorations.join(" ")}`);
1915
- return decls.join("; ");
1916
- }
1917
- function colorToCss(color) {
1918
- if (color.mode === "default") return null;
1919
- if (color.mode === "rgb") return `#${(color.value & 16777215).toString(16).padStart(6, "0")}`;
1920
- return xterm256Color(clampInt$1(color.value, 0, 255));
1921
- }
1922
- function xterm256Color(idx) {
1923
- const table16 = [
1924
- "#000000",
1925
- "#800000",
1926
- "#008000",
1927
- "#808000",
1928
- "#000080",
1929
- "#800080",
1930
- "#008080",
1931
- "#c0c0c0",
1932
- "#808080",
1933
- "#ff0000",
1934
- "#00ff00",
1935
- "#ffff00",
1936
- "#0000ff",
1937
- "#ff00ff",
1938
- "#00ffff",
1939
- "#ffffff"
1940
- ];
1941
- if (idx < 16) return table16[idx] ?? "#000000";
1942
- if (idx >= 16 && idx <= 231) {
1943
- const c = [
1944
- 0,
1945
- 95,
1946
- 135,
1947
- 175,
1948
- 215,
1949
- 255
1950
- ];
1951
- const n = idx - 16;
1952
- return `rgb(${c[Math.trunc(n / 36) % 6] ?? 0} ${c[Math.trunc(n / 6) % 6] ?? 0} ${c[n % 6] ?? 0})`;
2402
+ if (step.type === "pressKey") return `pressKey ${step.key}`;
2403
+ if (step.type === "sendMouse") return `sendMouse ${step.action} (${step.x},${step.y})`;
2404
+ if (step.type === "resize") return `resize ${step.cols}x${step.rows}`;
2405
+ if (step.type === "mark") return step.label ? `mark ${step.label}` : "mark";
2406
+ if (step.type === "sleep") return `sleep ${step.ms}ms`;
2407
+ if (step.type === "waitForText") {
2408
+ if (!showText) return step.text ? "waitForText (text)" : step.regex ? "waitForText (regex)" : "waitForText";
2409
+ if (step.text) return `waitFor "${truncateInline(step.text)}"`;
2410
+ if (step.regex) return `waitFor /${truncateInline(step.regex)}/`;
2411
+ return "waitForText";
1953
2412
  }
1954
- const v = clampInt$1(8 + (idx - 232) * 10, 0, 255);
1955
- return `rgb(${v} ${v} ${v})`;
2413
+ if (step.type === "waitForStableScreen") return "waitForStableScreen";
2414
+ if (step.type === "waitForExit") return "waitForExit";
2415
+ if (step.type === "expectMeta") return "expectMeta";
2416
+ if (step.type === "snapshot") return `snapshot ${step.kind}${step.saveAs ? ` as ${step.saveAs}` : ""}`;
2417
+ if (step.type === "expect") {
2418
+ const parts = [];
2419
+ if (step.equals !== void 0) parts.push("equals");
2420
+ if (step.contains?.length) parts.push(`contains(${step.contains.length})`);
2421
+ if (step.notContains?.length) parts.push(`notContains(${step.notContains.length})`);
2422
+ if (step.regex) parts.push("regex");
2423
+ return parts.length ? `expect ${parts.join(",")}` : "expect";
2424
+ }
2425
+ if (step.type === "expectGolden") return `expectGolden ${step.path}`;
2426
+ if (step.type === "assert") {
2427
+ if (!showText) return step.text ? "assert (text)" : step.regex ? "assert (regex)" : "assert";
2428
+ if (step.text) return `assert "${truncateInline(step.text)}"`;
2429
+ if (step.regex) return `assert /${truncateInline(step.regex)}/`;
2430
+ if (step.description) return `assert "${truncateInline(step.description)}"`;
2431
+ return "assert";
2432
+ }
2433
+ if (step.type === "assertSemantic") return "assertSemantic";
2434
+ return assertUnreachableStep(step);
1956
2435
  }
1957
- function diffLineIndices(previous, next) {
1958
- const out = /* @__PURE__ */ new Set();
1959
- const max = Math.max(previous.length, next.length);
1960
- for (let i = 0; i < max; i += 1) if ((previous[i] ?? "") !== (next[i] ?? "")) out.add(i);
1961
- return out;
2436
+ function truncateInline(text, maxChars = 60) {
2437
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
2438
+ if (normalized.length <= maxChars) return normalized;
2439
+ return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
1962
2440
  }
1963
- function formatHeaderLine(input) {
1964
- const cursorViewportRow = input.meta.baseY + input.meta.cursorY - input.meta.viewportY;
1965
- const cursorViewportCol = input.meta.cursorX;
1966
- return [
1967
- `session=${input.sessionId}`,
1968
- `scope=${input.scope}`,
1969
- `size=${input.meta.cols}x${input.meta.rows}`,
1970
- `buffer=${input.meta.bufferType}`,
1971
- `cursor=${cursorViewportCol + 1},${cursorViewportRow + 1}`,
1972
- `hash=${input.hash}`,
1973
- `changed=${input.changedCount}`
1974
- ].join(" ");
2441
+ function assertUnreachableStep(_step) {
2442
+ return "unknown";
1975
2443
  }
1976
- function coerceDisplayString(value) {
1977
- if (value === null || value === void 0) return "";
1978
- if (typeof value === "string") return value;
1979
- if (typeof value === "number" || typeof value === "boolean") return String(value);
2444
+ //#endregion
2445
+ //#region src/script/test_data_artifact.ts
2446
+ function writeTestDataArtifact(args) {
1980
2447
  try {
1981
- return JSON.stringify(value) ?? "";
1982
- } catch {
1983
- return "";
1984
- }
1985
- }
1986
- function escapeHtml(text) {
1987
- return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
2448
+ const testId = basename(args.artifactsDir);
2449
+ const outPath = args.resolveArtifactPath("test.data.js");
2450
+ mkdirSync(dirname(outPath), { recursive: true });
2451
+ const steps = args.executionSteps.map((s) => ({
2452
+ index: s.index + 1,
2453
+ type: s.step.type,
2454
+ label: formatPublicStepLabel(s.step),
2455
+ ok: s.ok,
2456
+ durationMs: s.durationMs,
2457
+ error: s.ok ? null : s.error ?? null
2458
+ }));
2459
+ const data = {
2460
+ version: 1,
2461
+ testId,
2462
+ scriptName: args.scriptName,
2463
+ ok: args.ok,
2464
+ error: args.ok ? null : args.error ?? null,
2465
+ stepCount: steps.length,
2466
+ steps,
2467
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2468
+ };
2469
+ const json = JSON.stringify(data).replaceAll("<", "\\u003c");
2470
+ writeFileSync(outPath, `globalThis.__ptywright = globalThis.__ptywright || {};
2471
+ globalThis.__ptywright.tests = globalThis.__ptywright.tests || {};
2472
+ globalThis.__ptywright.tests[${JSON.stringify(testId)}] = ${json};\n`, "utf8");
2473
+ } catch {}
1988
2474
  }
1989
- function getMeta(terminal) {
1990
- const buffer = terminal.buffer.active;
1991
- return {
1992
- cols: terminal.cols,
1993
- rows: terminal.rows,
1994
- bufferType: buffer.type,
1995
- viewportY: buffer.viewportY,
1996
- baseY: buffer.baseY,
1997
- length: buffer.length,
1998
- cursorX: buffer.cursorX,
1999
- cursorY: buffer.cursorY
2000
- };
2475
+ //#endregion
2476
+ //#region src/script/trace_artifacts.ts
2477
+ async function writeTraceArtifacts(args) {
2478
+ if (!args.saveCast && !args.saveReport) return;
2479
+ const snapshot = await args.session.snapshotCast();
2480
+ if (args.saveCast) {
2481
+ mkdirSync(dirname(args.castPath), { recursive: true });
2482
+ writeFileSync(args.castPath, snapshot.cast, "utf8");
2483
+ }
2484
+ if (args.saveReport) {
2485
+ const artifactHrefs = buildReportArtifactHrefs({
2486
+ reportPath: args.reportPath,
2487
+ castPath: args.saveCast ? args.castPath : null,
2488
+ artifactsDir: args.artifactsDir,
2489
+ includeFailures: args.result?.ok === false
2490
+ });
2491
+ const html = await generateTraceReportHtml(snapshot.cast, {
2492
+ scope: args.reportScope,
2493
+ maxFrames: args.reportMaxFrames,
2494
+ scriptName: args.scriptName,
2495
+ result: args.result,
2496
+ artifacts: artifactHrefs,
2497
+ steps: args.executionSteps
2498
+ });
2499
+ mkdirSync(dirname(args.reportPath), { recursive: true });
2500
+ writeFileSync(args.reportPath, html, "utf8");
2501
+ ensureAsciinemaPlayerAssets(args.reportPath);
2502
+ }
2001
2503
  }
2002
- function parseAsciicast(cast) {
2003
- const lines = cast.trimEnd().split("\n");
2004
- const header = safeJsonObject(lines[0]);
2005
- const events = [];
2006
- for (const line of lines.slice(1)) {
2007
- if (!line.trim()) continue;
2008
- const value = JSON.parse(line);
2009
- if (!Array.isArray(value) || value.length < 3) continue;
2010
- const time = Number(value[0]);
2011
- const type = String(value[1]);
2012
- const data = String(value[2]);
2013
- if (!Number.isFinite(time)) continue;
2014
- if (type === "o" || type === "i" || type === "r" || type === "m") events.push([
2015
- time,
2016
- type,
2017
- data
2018
- ]);
2504
+ function buildReportArtifactHrefs(args) {
2505
+ const items = {};
2506
+ if (args.castPath) items.castHref = relativeHref(args.reportPath, args.castPath);
2507
+ if (args.includeFailures) {
2508
+ items.failureErrorHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.error.txt"));
2509
+ items.failureStepHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.step.json"));
2510
+ items.failureLastTextHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.txt"));
2511
+ items.failureLastViewHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.view.txt"));
2019
2512
  }
2513
+ return Object.keys(items).length ? items : void 0;
2514
+ }
2515
+ //#endregion
2516
+ //#region src/script/runner_paths.ts
2517
+ function resolveArtifactsDir(script, scriptName, override) {
2518
+ if (override?.trim()) return resolve(override.trim());
2519
+ if (script.artifactsDir?.trim()) return resolve(script.artifactsDir.trim());
2520
+ return resolve(".tmp", "runs", scriptName);
2521
+ }
2522
+ function createScriptPathResolvers(artifactsDir) {
2020
2523
  return {
2021
- header,
2022
- events
2524
+ resolveGoldenPath: (path) => isAbsolute(path) ? path : resolve(process.cwd(), path),
2525
+ resolveArtifactPath: (path) => isAbsolute(path) ? path : resolve(artifactsDir, path)
2023
2526
  };
2024
2527
  }
2025
- function safeJsonObject(line) {
2026
- if (!line) return {};
2027
- try {
2028
- const value = JSON.parse(line);
2029
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2030
- } catch {
2031
- return {};
2032
- }
2528
+ //#endregion
2529
+ //#region src/script/runner_load.ts
2530
+ async function loadJsonScriptFileWithDefaultName(scriptPath) {
2531
+ const raw = await Bun.file(scriptPath).text();
2532
+ const parsedJson = JSON.parse(raw);
2533
+ const baseName = basename(scriptPath, extname(scriptPath));
2534
+ return parsedJson && typeof parsedJson === "object" && !Array.isArray(parsedJson) && !("name" in parsedJson) ? {
2535
+ ...parsedJson,
2536
+ name: baseName
2537
+ } : parsedJson;
2033
2538
  }
2034
- function getTermInfo(header) {
2035
- if (Number(header.version ?? 2) === 3) {
2036
- const term = header.term;
2037
- return {
2038
- cols: clampInt$1(Number(term?.cols ?? 80), 1, 500),
2039
- rows: clampInt$1(Number(term?.rows ?? 24), 1, 300),
2040
- type: typeof term?.type === "string" ? term.type : "xterm-256color"
2041
- };
2539
+ //#endregion
2540
+ //#region src/script/frame_text.ts
2541
+ function normalizeNewlines(text) {
2542
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2543
+ }
2544
+ function normalizeLineWidth(line, cols, trimRight) {
2545
+ const clipped = line.length > cols ? line.slice(0, cols) : line;
2546
+ return trimRight ? clipped.trimEnd() : clipped.padEnd(cols, " ");
2547
+ }
2548
+ function trimBottomEmptyLines(lines) {
2549
+ let end = lines.length;
2550
+ while (end > 0 && (lines[end - 1] ?? "").trim() === "") end -= 1;
2551
+ return lines.slice(0, end);
2552
+ }
2553
+ function sliceLines(lines, options) {
2554
+ if (options?.maxLines !== void 0) return lines.slice(0, Math.max(0, Math.trunc(options.maxLines)));
2555
+ if (options?.tailLines !== void 0) {
2556
+ const tail = Math.max(0, Math.trunc(options.tailLines));
2557
+ return lines.slice(Math.max(0, lines.length - tail));
2042
2558
  }
2043
- return {
2044
- cols: clampInt$1(Number(header.width ?? 80), 1, 500),
2045
- rows: clampInt$1(Number(header.height ?? 24), 1, 300),
2046
- type: typeof header.term === "string" ? header.term : "xterm-256color"
2047
- };
2559
+ return lines;
2048
2560
  }
2049
- function parseResize(value) {
2050
- const match = /^\s*(\d+)x(\d+)\s*$/.exec(value);
2051
- if (!match) return null;
2052
- return {
2053
- cols: clampInt$1(Number(match[1] ?? 0), 1, 500),
2054
- rows: clampInt$1(Number(match[2] ?? 0), 1, 300)
2055
- };
2561
+ function inferCols(frames) {
2562
+ return clampInt(frames.reduce((acc, frame) => {
2563
+ const lines = normalizeNewlines(frame).split("\n");
2564
+ return Math.max(acc, ...lines.map((line) => line.length));
2565
+ }, 0) || 80, 1, 500);
2056
2566
  }
2057
- function clampInt$1(value, min, max) {
2567
+ function inferRows(frames) {
2568
+ return clampInt(frames.reduce((acc, frame) => {
2569
+ return Math.max(acc, normalizeNewlines(frame).split("\n").length);
2570
+ }, 0) || 24, 1, 300);
2571
+ }
2572
+ function clampInt(value, min, max) {
2058
2573
  if (!Number.isFinite(value)) return min;
2059
2574
  const int = Math.trunc(value);
2060
2575
  if (int < min) return min;
2061
2576
  if (int > max) return max;
2062
2577
  return int;
2063
2578
  }
2064
- if (import.meta.main) {
2065
- const inputPath = process.argv[2];
2066
- if (!inputPath) {
2067
- console.error("Usage: bun run src/trace/report.ts <path/to/cast>");
2068
- process.exit(2);
2579
+ //#endregion
2580
+ //#region src/script/frame_snapshot.ts
2581
+ function snapshotFrameText(inputLines, options) {
2582
+ if (options?.maxLines !== void 0 && options.tailLines !== void 0) throw new Error("snapshotText: maxLines and tailLines are mutually exclusive");
2583
+ let lines = inputLines;
2584
+ if (options?.trimBottom ?? true) lines = trimBottomEmptyLines(lines);
2585
+ lines = sliceLines(lines, options);
2586
+ lines = applyTextMaskRules(lines, options?.mask);
2587
+ const text = lines.join("\n");
2588
+ return {
2589
+ text,
2590
+ hash: fnv1a32(text)
2591
+ };
2592
+ }
2593
+ function snapshotFrameAnsiFromText(text, hash) {
2594
+ return {
2595
+ ansi: text,
2596
+ plain: text,
2597
+ hash,
2598
+ lines: text.split("\n").map((line) => ({
2599
+ ansi: line,
2600
+ plain: line
2601
+ }))
2602
+ };
2603
+ }
2604
+ function snapshotFrameGrid(args) {
2605
+ const grid = {
2606
+ cols: args.cols,
2607
+ rows: args.rows,
2608
+ bufferType: "normal",
2609
+ cursorX: 0,
2610
+ cursorY: args.cursorY,
2611
+ viewportY: 0,
2612
+ lines: args.lines,
2613
+ styleRuns: args.includeStyles ? args.lines.map(() => []) : void 0
2614
+ };
2615
+ return {
2616
+ grid,
2617
+ hash: fnv1a32(JSON.stringify(grid))
2618
+ };
2619
+ }
2620
+ //#endregion
2621
+ //#region src/script/frame_source.ts
2622
+ async function resolveLaunchFrames(launch, cwd, backend) {
2623
+ const frames = [];
2624
+ if (launch.frames?.length) frames.push(...normalizeFrames(launch.frames));
2625
+ if (launch.frame !== void 0) frames.push(launch.frame);
2626
+ if (launch.framePath) {
2627
+ const path = resolveLaunchPath(cwd, launch.framePath);
2628
+ frames.push(readFileSync(path, "utf8").replace(/\n$/, ""));
2629
+ }
2630
+ if (launch.frameModule) frames.push(...await loadFrameModule(resolveLaunchPath(cwd, launch.frameModule), backend));
2631
+ if (frames.length === 0) throw new Error(`launch.backend=${backend} requires frame, frames, framePath, or frameModule`);
2632
+ return frames;
2633
+ }
2634
+ async function loadFrameModule(modulePath, backend) {
2635
+ return normalizeFrames(await materializeFrameSource(selectModuleFrameSource(await import(pathToFileURL(modulePath).href), backend)));
2636
+ }
2637
+ function selectModuleFrameSource(mod, backend) {
2638
+ if (mod.frames !== void 0) return mod.frames;
2639
+ if (mod.default !== void 0) return mod.default;
2640
+ if (backend === "ink" && mod.lastFrame !== void 0) return mod.lastFrame;
2641
+ if (backend === "ink" && mod.frame !== void 0) return mod.frame;
2642
+ if (backend === "ratatui" && mod.snapshot !== void 0) return mod.snapshot;
2643
+ if (mod.frame !== void 0) return mod.frame;
2644
+ if (mod.snapshot !== void 0) return mod.snapshot;
2645
+ throw new Error(`frame module did not export frames/default/frame/snapshot/lastFrame`);
2646
+ }
2647
+ async function materializeFrameSource(source) {
2648
+ if (typeof source === "function") return await source();
2649
+ return source;
2650
+ }
2651
+ function normalizeFrames(source) {
2652
+ if (Array.isArray(source)) return source.map((frame) => normalizeFrame(frame));
2653
+ return [normalizeFrame(source)];
2654
+ }
2655
+ function normalizeFrame(frame) {
2656
+ if (typeof frame === "string") return normalizeNewlines(frame).replace(/\n$/, "");
2657
+ if (typeof frame === "object" && frame !== null) {
2658
+ const value = frame;
2659
+ const text = typeof value.text === "string" ? value.text : typeof value.frame === "string" ? value.frame : typeof value.snapshot === "string" ? value.snapshot : typeof value.lastFrame === "string" ? value.lastFrame : void 0;
2660
+ if (text !== void 0) return normalizeNewlines(text).replace(/\n$/, "");
2661
+ }
2662
+ throw new Error("frame entries must be strings or objects with text/frame/snapshot/lastFrame");
2663
+ }
2664
+ function resolveLaunchPath(cwd, path) {
2665
+ return isAbsolute(path) ? path : resolve(cwd, path);
2666
+ }
2667
+ //#endregion
2668
+ //#region src/script/frame_wait.ts
2669
+ async function waitForFrameText(snapshotText, args) {
2670
+ const startedAt = Date.now();
2671
+ while (true) {
2672
+ const snapshot = await snapshotText({
2673
+ scope: args.scope,
2674
+ captureFrame: true
2675
+ });
2676
+ if (args.text && snapshot.text.includes(args.text)) return {
2677
+ found: true,
2678
+ ...snapshot
2679
+ };
2680
+ if (args.regex && args.regex.test(snapshot.text)) return {
2681
+ found: true,
2682
+ ...snapshot
2683
+ };
2684
+ if (Date.now() - startedAt >= args.timeoutMs) return {
2685
+ found: false,
2686
+ ...snapshot
2687
+ };
2688
+ await sleep(Math.max(1, args.intervalMs));
2069
2689
  }
2070
- const html = await generateTraceReportHtml(await Bun.file(inputPath).text());
2071
- const outPath = join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}.report.html`);
2072
- writeFileSync(outPath, html);
2073
- ensureAsciinemaPlayerAssets(outPath);
2074
- console.log(outPath);
2690
+ }
2691
+ async function waitForFrameStableScreen(snapshotText) {
2692
+ return {
2693
+ stable: true,
2694
+ ...await snapshotText({ captureFrame: true })
2695
+ };
2075
2696
  }
2076
2697
  //#endregion
2077
2698
  //#region src/script/frame_session.ts
@@ -2148,17 +2769,11 @@ var FrameSession = class {
2148
2769
  baseY: 0,
2149
2770
  length: this.visibleLines({ trimRight: true }).length,
2150
2771
  cursorX: 0,
2151
- cursorY: Math.max(0, Math.min(this.rowsValue - 1, this.currentLines().length - 1))
2772
+ cursorY: this.cursorY()
2152
2773
  };
2153
2774
  }
2154
2775
  async snapshotText(options) {
2155
- if (options?.maxLines !== void 0 && options.tailLines !== void 0) throw new Error("snapshotText: maxLines and tailLines are mutually exclusive");
2156
- let lines = options?.scope === "buffer" ? this.currentLines({ trimRight: options?.trimRight }) : this.visibleLines({ trimRight: options?.trimRight });
2157
- if (options?.trimBottom ?? true) lines = trimBottomEmptyLines(lines);
2158
- lines = sliceLines(lines, options);
2159
- lines = applyTextMaskRules(lines, options?.mask);
2160
- const text = lines.join("\n");
2161
- const hash = fnv1a32(text);
2776
+ const { text, hash } = snapshotFrameText(options?.scope === "buffer" ? this.currentLines({ trimRight: options?.trimRight }) : this.visibleLines({ trimRight: options?.trimRight }), options);
2162
2777
  if (options?.captureFrame ?? true) this.captureFrame(text, hash);
2163
2778
  return {
2164
2779
  text,
@@ -2167,29 +2782,17 @@ var FrameSession = class {
2167
2782
  }
2168
2783
  async snapshotAnsi(options) {
2169
2784
  const { text, hash } = await this.snapshotText(options);
2170
- return {
2171
- ansi: text,
2172
- plain: text,
2173
- hash,
2174
- lines: text.split("\n").map((line) => ({
2175
- ansi: line,
2176
- plain: line
2177
- }))
2178
- };
2785
+ return snapshotFrameAnsiFromText(text, hash);
2179
2786
  }
2180
2787
  async snapshotGrid(options) {
2181
2788
  const lines = this.visibleLines({ trimRight: options?.trimRight });
2182
- const grid = {
2789
+ const { grid, hash } = snapshotFrameGrid({
2790
+ lines,
2183
2791
  cols: this.colsValue,
2184
2792
  rows: this.rowsValue,
2185
- bufferType: "normal",
2186
- cursorX: 0,
2187
- cursorY: Math.max(0, Math.min(this.rowsValue - 1, this.currentLines().length - 1)),
2188
- viewportY: 0,
2189
- lines,
2190
- styleRuns: options?.includeStyles ? lines.map(() => []) : void 0
2191
- };
2192
- const hash = fnv1a32(JSON.stringify(grid));
2793
+ cursorY: this.cursorY(),
2794
+ includeStyles: options?.includeStyles
2795
+ });
2193
2796
  if (options?.captureFrame ?? true) this.captureFrame(lines.join("\n"), hash);
2194
2797
  return {
2195
2798
  grid,
@@ -2201,32 +2804,10 @@ var FrameSession = class {
2201
2804
  return this.trace.snapshot({ tailEvents: options?.tailEvents });
2202
2805
  }
2203
2806
  async waitForText(args) {
2204
- const startedAt = Date.now();
2205
- while (true) {
2206
- const snapshot = await this.snapshotText({
2207
- scope: args.scope,
2208
- captureFrame: true
2209
- });
2210
- if (args.text && snapshot.text.includes(args.text)) return {
2211
- found: true,
2212
- ...snapshot
2213
- };
2214
- if (args.regex && args.regex.test(snapshot.text)) return {
2215
- found: true,
2216
- ...snapshot
2217
- };
2218
- if (Date.now() - startedAt >= args.timeoutMs) return {
2219
- found: false,
2220
- ...snapshot
2221
- };
2222
- await sleep(Math.max(1, args.intervalMs));
2223
- }
2807
+ return waitForFrameText((options) => this.snapshotText(options), args);
2224
2808
  }
2225
2809
  async waitForStableScreen() {
2226
- return {
2227
- stable: true,
2228
- ...await this.snapshotText({ captureFrame: true })
2229
- };
2810
+ return waitForFrameStableScreen((options) => this.snapshotText(options));
2230
2811
  }
2231
2812
  isClosed() {
2232
2813
  return this.closed !== null;
@@ -2261,6 +2842,9 @@ var FrameSession = class {
2261
2842
  while (lines.length < this.rowsValue) lines.push("");
2262
2843
  return lines;
2263
2844
  }
2845
+ cursorY() {
2846
+ return Math.max(0, Math.min(this.rowsValue - 1, this.currentLines().length - 1));
2847
+ }
2264
2848
  captureFrame(text, hash) {
2265
2849
  this.snapshotRing.push({
2266
2850
  atMs: Date.now(),
@@ -2289,374 +2873,424 @@ async function createFrameSessionFromLaunch(args) {
2289
2873
  advanceOnInput: args.launch.advanceOnInput
2290
2874
  });
2291
2875
  }
2292
- async function resolveLaunchFrames(launch, cwd, backend) {
2293
- const frames = [];
2294
- if (launch.frames?.length) frames.push(...normalizeFrames(launch.frames));
2295
- if (launch.frame !== void 0) frames.push(launch.frame);
2296
- if (launch.framePath) {
2297
- const path = resolveLaunchPath(cwd, launch.framePath);
2298
- frames.push(readFileSync(path, "utf8").replace(/\n$/, ""));
2876
+ //#endregion
2877
+ //#region src/script/runner_session.ts
2878
+ async function launchScriptSession(args) {
2879
+ const { launch, scriptName } = args;
2880
+ const cwd = launch.cwd ? resolve(process.cwd(), launch.cwd) : process.cwd();
2881
+ if ((launch.backend ?? "pty") === "pty") {
2882
+ const sessions = new SessionManager({ snapshotRingSize: 50 });
2883
+ if (!launch.command) throw new Error("launch.command is required when backend=pty");
2884
+ return {
2885
+ session: sessions.launchSession({
2886
+ command: launch.command,
2887
+ args: launch.args ?? [],
2888
+ cwd,
2889
+ env: launch.env,
2890
+ cols: launch.cols,
2891
+ rows: launch.rows,
2892
+ name: launch.name
2893
+ }),
2894
+ closeSession: () => sessions.closeAll()
2895
+ };
2299
2896
  }
2300
- if (launch.frameModule) frames.push(...await loadFrameModule(resolveLaunchPath(cwd, launch.frameModule), backend));
2301
- if (frames.length === 0) throw new Error(`launch.backend=${backend} requires frame, frames, framePath, or frameModule`);
2302
- return frames;
2303
- }
2304
- async function loadFrameModule(modulePath, backend) {
2305
- return normalizeFrames(await materializeFrameSource(selectModuleFrameSource(await import(pathToFileURL(modulePath).href), backend)));
2306
- }
2307
- function selectModuleFrameSource(mod, backend) {
2308
- if (mod.frames !== void 0) return mod.frames;
2309
- if (mod.default !== void 0) return mod.default;
2310
- if (backend === "ink" && mod.lastFrame !== void 0) return mod.lastFrame;
2311
- if (backend === "ink" && mod.frame !== void 0) return mod.frame;
2312
- if (backend === "ratatui" && mod.snapshot !== void 0) return mod.snapshot;
2313
- if (mod.frame !== void 0) return mod.frame;
2314
- if (mod.snapshot !== void 0) return mod.snapshot;
2315
- throw new Error(`frame module did not export frames/default/frame/snapshot/lastFrame`);
2897
+ const session = await createFrameSessionFromLaunch({
2898
+ launch,
2899
+ cwd,
2900
+ title: scriptName
2901
+ });
2902
+ return {
2903
+ session,
2904
+ closeSession: () => session.close()
2905
+ };
2316
2906
  }
2317
- async function materializeFrameSource(source) {
2318
- if (typeof source === "function") return await source();
2319
- return source;
2907
+ //#endregion
2908
+ //#region src/script/runner_trace.ts
2909
+ function resolveScriptTraceArtifacts(args) {
2910
+ const trace = args.script.trace ?? {};
2911
+ return {
2912
+ saveCast: trace.saveCast ?? true,
2913
+ saveReport: trace.saveReport ?? true,
2914
+ castPath: args.resolveArtifactPath(trace.castPath ?? `${args.scriptName}.cast`),
2915
+ reportPath: args.resolveArtifactPath(trace.reportPath ?? `${args.scriptName}.report.html`),
2916
+ reportScope: trace.reportScope,
2917
+ reportMaxFrames: trace.reportMaxFrames
2918
+ };
2320
2919
  }
2321
- function normalizeFrames(source) {
2322
- if (Array.isArray(source)) return source.map((frame) => normalizeFrame(frame));
2323
- return [normalizeFrame(source)];
2920
+ //#endregion
2921
+ //#region src/script/snapshot.ts
2922
+ function persistSnapshotRecord(args) {
2923
+ const saveAs = args.saveAs?.trim();
2924
+ if (saveAs) args.snapshots.set(saveAs, args.record);
2925
+ const saveTo = args.saveTo?.trim();
2926
+ if (!saveTo) return;
2927
+ const path = args.resolveArtifactPath(saveTo);
2928
+ mkdirSync(dirname(path), { recursive: true });
2929
+ writeFileSync(path, `${args.record.text}\n`, "utf8");
2324
2930
  }
2325
- function normalizeFrame(frame) {
2326
- if (typeof frame === "string") return normalizeNewlines(frame).replace(/\n$/, "");
2327
- if (typeof frame === "object" && frame !== null) {
2328
- const value = frame;
2329
- const text = typeof value.text === "string" ? value.text : typeof value.frame === "string" ? value.frame : typeof value.snapshot === "string" ? value.snapshot : typeof value.lastFrame === "string" ? value.lastFrame : void 0;
2330
- if (text !== void 0) return normalizeNewlines(text).replace(/\n$/, "");
2931
+ function selectSnapshot(last, snapshots, from) {
2932
+ const key = from?.trim() ? from.trim() : "last";
2933
+ if (key === "last") {
2934
+ if (!last) throw new Error("expect: no previous snapshot (from=last)");
2935
+ return last;
2331
2936
  }
2332
- throw new Error("frame entries must be strings or objects with text/frame/snapshot/lastFrame");
2937
+ const found = snapshots.get(key);
2938
+ if (!found) throw new Error(`expect: unknown snapshot reference: ${key}`);
2939
+ return found;
2333
2940
  }
2334
- function resolveLaunchPath(cwd, path) {
2335
- return isAbsolute(path) ? path : resolve(cwd, path);
2941
+ function assertRecordMatches(record, step, stepIndex) {
2942
+ if (step.equals !== void 0 && record.text !== step.equals) throw new Error(`step ${stepIndex + 1} expect.equals failed`);
2943
+ if (step.contains && step.contains.length > 0) {
2944
+ for (const item of step.contains) if (!record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.contains failed: ${JSON.stringify(item)}`);
2945
+ }
2946
+ if (step.notContains && step.notContains.length > 0) {
2947
+ for (const item of step.notContains) if (record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.notContains failed: ${JSON.stringify(item)}`);
2948
+ }
2949
+ if (step.regex) {
2950
+ if (!new RegExp(step.regex).test(record.text)) throw new Error(`step ${stepIndex + 1} expect.regex failed: ${JSON.stringify(step.regex)}`);
2951
+ }
2336
2952
  }
2337
- function normalizeNewlines(text) {
2338
- return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2953
+ function assertGoldenText(path, text, update) {
2954
+ if (update) {
2955
+ mkdirSync(dirname(path), { recursive: true });
2956
+ writeFileSync(path, text, "utf8");
2957
+ return;
2958
+ }
2959
+ if (text !== readFileSync(path, "utf8")) throw new Error(`golden mismatch: ${path}`);
2339
2960
  }
2340
- function normalizeLineWidth(line, cols, trimRight) {
2341
- const clipped = line.length > cols ? line.slice(0, cols) : line;
2342
- return trimRight ? clipped.trimEnd() : clipped.padEnd(cols, " ");
2961
+ async function snapshotAfterStep(session) {
2962
+ try {
2963
+ const captured = await session.snapshotText({
2964
+ scope: "visible",
2965
+ trimRight: true,
2966
+ trimBottom: true,
2967
+ captureFrame: true
2968
+ });
2969
+ const lines = captured.text.split("\n");
2970
+ const view = formatSnapshotView({
2971
+ sessionId: session.id,
2972
+ scope: "visible",
2973
+ hash: captured.hash,
2974
+ lines,
2975
+ meta: session.getMeta(),
2976
+ lineNumbers: true
2977
+ });
2978
+ return {
2979
+ kind: "view",
2980
+ hash: captured.hash,
2981
+ text: view
2982
+ };
2983
+ } catch {
2984
+ return null;
2985
+ }
2343
2986
  }
2344
- function trimBottomEmptyLines(lines) {
2345
- let end = lines.length;
2346
- while (end > 0 && (lines[end - 1] ?? "").trim() === "") end -= 1;
2347
- return lines.slice(0, end);
2987
+ async function snapshotStep(session, step) {
2988
+ if (step.kind === "grid") {
2989
+ if (step.mask && step.mask.length > 0) throw new Error("snapshot.kind=grid does not support mask (use text/view instead)");
2990
+ const { grid, hash } = await session.snapshotGrid({
2991
+ trimRight: step.trimRight,
2992
+ includeStyles: step.includeStyles,
2993
+ captureFrame: true
2994
+ });
2995
+ return {
2996
+ kind: step.kind,
2997
+ hash,
2998
+ text: JSON.stringify(grid, null, 2)
2999
+ };
3000
+ }
3001
+ if (step.kind === "ansi" || step.kind === "view_ansi") {
3002
+ const { ansi, hash } = await session.snapshotAnsi({
3003
+ scope: step.scope,
3004
+ trimRight: step.trimRight,
3005
+ trimBottom: step.trimBottom ?? true,
3006
+ maxLines: step.maxLines,
3007
+ tailLines: step.tailLines,
3008
+ mask: step.mask
3009
+ });
3010
+ if (step.kind === "ansi") return {
3011
+ kind: step.kind,
3012
+ hash,
3013
+ text: ansi
3014
+ };
3015
+ const lines = ansi.split("\n");
3016
+ const view = formatSnapshotView({
3017
+ sessionId: session.id,
3018
+ scope: step.scope ?? "visible",
3019
+ hash,
3020
+ lines,
3021
+ meta: session.getMeta(),
3022
+ lineNumbers: step.lineNumbers
3023
+ });
3024
+ return {
3025
+ kind: step.kind,
3026
+ hash,
3027
+ text: view
3028
+ };
3029
+ }
3030
+ const { text, hash } = await session.snapshotText({
3031
+ scope: step.scope,
3032
+ trimRight: step.trimRight,
3033
+ trimBottom: step.trimBottom ?? true,
3034
+ maxLines: step.maxLines,
3035
+ tailLines: step.tailLines,
3036
+ captureFrame: true,
3037
+ mask: step.mask
3038
+ });
3039
+ if (step.kind === "text") return {
3040
+ kind: step.kind,
3041
+ hash,
3042
+ text
3043
+ };
3044
+ const lines = text.split("\n");
3045
+ const view = formatSnapshotView({
3046
+ sessionId: session.id,
3047
+ scope: step.scope ?? "visible",
3048
+ hash,
3049
+ lines,
3050
+ meta: session.getMeta(),
3051
+ lineNumbers: step.lineNumbers
3052
+ });
3053
+ return {
3054
+ kind: step.kind,
3055
+ hash,
3056
+ text: view
3057
+ };
2348
3058
  }
2349
- function sliceLines(lines, options) {
2350
- if (options?.maxLines !== void 0) return lines.slice(0, Math.max(0, Math.trunc(options.maxLines)));
2351
- if (options?.tailLines !== void 0) {
2352
- const tail = Math.max(0, Math.trunc(options.tailLines));
2353
- return lines.slice(Math.max(0, lines.length - tail));
3059
+ //#endregion
3060
+ //#region src/script/assertion_step_runner_helpers.ts
3061
+ async function expectSessionMeta(session, step) {
3062
+ await session.flush();
3063
+ const meta = session.getMeta();
3064
+ if (step.bufferType !== void 0 && meta.bufferType !== step.bufferType) throw new Error(`expectMeta.bufferType mismatch (got ${meta.bufferType})`);
3065
+ if (step.cols !== void 0 && meta.cols !== step.cols) throw new Error(`expectMeta.cols mismatch (got ${meta.cols})`);
3066
+ if (step.rows !== void 0 && meta.rows !== step.rows) throw new Error(`expectMeta.rows mismatch (got ${meta.rows})`);
3067
+ if (step.cursor) {
3068
+ const cursorViewportRow = meta.baseY + meta.cursorY - meta.viewportY;
3069
+ const actual = {
3070
+ x: meta.cursorX + 1,
3071
+ y: cursorViewportRow + 1
3072
+ };
3073
+ if (actual.x !== step.cursor.x || actual.y !== step.cursor.y) throw new Error(`expectMeta.cursor mismatch (got ${actual.x},${actual.y})`);
2354
3074
  }
2355
- return lines;
2356
3075
  }
2357
- function inferCols(frames) {
2358
- return clampInt(frames.reduce((acc, frame) => {
2359
- const lines = normalizeNewlines(frame).split("\n");
2360
- return Math.max(acc, ...lines.map((line) => line.length));
2361
- }, 0) || 80, 1, 500);
3076
+ async function runAssertStep(session, step, stepIndex) {
3077
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
3078
+ if (!(await session.waitForText({
3079
+ scope: step.scope,
3080
+ text: step.text,
3081
+ regex,
3082
+ timeoutMs: 0,
3083
+ intervalMs: 0
3084
+ })).found) throw new Error(`step ${stepIndex + 1} assert failed: ${step.description || step.text || step.regex || "pattern mismatch"}`);
2362
3085
  }
2363
- function inferRows(frames) {
2364
- return clampInt(frames.reduce((acc, frame) => {
2365
- return Math.max(acc, normalizeNewlines(frame).split("\n").length);
2366
- }, 0) || 24, 1, 300);
3086
+ //#endregion
3087
+ //#region src/script/custom_step_runner.ts
3088
+ async function runCustomStep(args) {
3089
+ const handler = args.stepHandlers?.[args.step.name];
3090
+ if (!handler) throw new Error(`custom handler not found: ${args.step.name}`);
3091
+ return await handler({
3092
+ session: args.session,
3093
+ stepIndex: args.stepIndex,
3094
+ last: args.last,
3095
+ snapshots: args.snapshots,
3096
+ artifactsDir: args.artifactsDir,
3097
+ resolveArtifactPath: args.resolveArtifactPath,
3098
+ resolveGoldenPath: args.resolveGoldenPath,
3099
+ updateGoldens: args.updateGoldens,
3100
+ captureSnapshot: async (snapshotConfig) => {
3101
+ const record = await snapshotStep(args.session, {
3102
+ type: "snapshot",
3103
+ ...snapshotConfig
3104
+ });
3105
+ persistSnapshotRecord({
3106
+ record,
3107
+ saveAs: snapshotConfig.saveAs,
3108
+ saveTo: snapshotConfig.saveTo,
3109
+ snapshots: args.snapshots,
3110
+ resolveArtifactPath: args.resolveArtifactPath
3111
+ });
3112
+ return record;
3113
+ },
3114
+ getSnapshot: (from) => selectSnapshot(args.last, args.snapshots, from),
3115
+ writeArtifactText: (path, text) => {
3116
+ const resolved = args.resolveArtifactPath(path);
3117
+ mkdirSync(dirname(resolved), { recursive: true });
3118
+ writeFileSync(resolved, text, "utf8");
3119
+ },
3120
+ assertGoldenText: (path, text) => {
3121
+ assertGoldenText(args.resolveGoldenPath(path), text, args.updateGoldens);
3122
+ }
3123
+ }, args.step) ?? args.last;
2367
3124
  }
2368
- function clampInt(value, min, max) {
2369
- if (!Number.isFinite(value)) return min;
2370
- const int = Math.trunc(value);
2371
- if (int < min) return min;
2372
- if (int > max) return max;
2373
- return int;
3125
+ //#endregion
3126
+ //#region src/script/input_step_runner_helpers.ts
3127
+ function runMouseStep(session, step) {
3128
+ const modifiers = step.shift || step.alt || step.ctrl ? {
3129
+ shift: step.shift,
3130
+ alt: step.alt,
3131
+ ctrl: step.ctrl
3132
+ } : void 0;
3133
+ session.sendMouse({
3134
+ action: step.action,
3135
+ x: step.x,
3136
+ y: step.y,
3137
+ button: step.button,
3138
+ modifiers
3139
+ });
2374
3140
  }
2375
3141
  //#endregion
2376
- //#region src/script/schema.ts
2377
- const textMaskRuleSchema = z.object({
2378
- regex: z.string().min(1),
2379
- flags: z.string().optional(),
2380
- replacement: z.string().optional(),
2381
- preserveLength: z.boolean().optional()
2382
- });
2383
- const launchConfigSchema = z.object({
2384
- backend: z.enum([
2385
- "pty",
2386
- "frames",
2387
- "ink",
2388
- "ratatui"
2389
- ]).optional(),
2390
- command: z.string().min(1).optional(),
2391
- args: z.array(z.string()).optional(),
2392
- cwd: z.string().optional(),
2393
- env: z.record(z.string()).optional(),
2394
- cols: z.number().int().positive().optional(),
2395
- rows: z.number().int().positive().optional(),
2396
- name: z.string().optional(),
2397
- frame: z.string().optional(),
2398
- frames: z.array(z.union([z.string(), z.object({
2399
- name: z.string().optional(),
2400
- text: z.string().optional(),
2401
- frame: z.string().optional(),
2402
- snapshot: z.string().optional(),
2403
- lastFrame: z.string().optional()
2404
- })])).optional(),
2405
- framePath: z.string().optional(),
2406
- frameModule: z.string().optional(),
2407
- advanceOnInput: z.boolean().optional()
2408
- }).superRefine((value, ctx) => {
2409
- if ((value.backend ?? "pty") === "pty") {
2410
- if (!value.command) ctx.addIssue({
2411
- code: z.ZodIssueCode.custom,
2412
- path: ["command"],
2413
- message: "launch.command is required when backend=pty"
3142
+ //#region src/script/wait_step_runner_helpers.ts
3143
+ async function waitForTextStep(session, step, stepIndex) {
3144
+ const regex = step.regex ? new RegExp(step.regex) : void 0;
3145
+ if (!(await session.waitForText({
3146
+ scope: step.scope,
3147
+ text: step.text,
3148
+ regex,
3149
+ timeoutMs: step.timeoutMs ?? 1e4,
3150
+ intervalMs: step.intervalMs ?? 100
3151
+ })).found) throw new Error(`step ${stepIndex + 1} waitForText not found: ${step.text ?? step.regex ?? ""}`);
3152
+ }
3153
+ async function waitForStableScreenStep(session, step, stepIndex) {
3154
+ if (!(await session.waitForStableScreen({
3155
+ timeoutMs: step.timeoutMs ?? 1e4,
3156
+ quietMs: step.quietMs ?? 400,
3157
+ intervalMs: step.intervalMs ?? 80
3158
+ })).stable) throw new Error(`step ${stepIndex + 1} waitForStableScreen timed out`);
3159
+ }
3160
+ async function waitForExitStep(session, step) {
3161
+ const startedAt = Date.now();
3162
+ const timeoutMs = step.timeoutMs ?? 1e4;
3163
+ const intervalMs = step.intervalMs ?? 50;
3164
+ while (Date.now() - startedAt <= timeoutMs) {
3165
+ if (session.isClosed()) break;
3166
+ await sleep(intervalMs);
3167
+ }
3168
+ const reason = session.getCloseReason();
3169
+ if (!reason) throw new Error("waitForExit timed out");
3170
+ if (reason.type !== "process_exit") throw new Error("waitForExit: session was closed by user");
3171
+ if (step.exitCode !== void 0 && reason.exitCode !== step.exitCode) throw new Error(`waitForExit: exitCode mismatch (got ${reason.exitCode})`);
3172
+ if (step.signal !== void 0 && reason.signal !== step.signal) throw new Error(`waitForExit: signal mismatch (got ${String(reason.signal ?? "")})`);
3173
+ }
3174
+ //#endregion
3175
+ //#region src/script/step_runner.ts
3176
+ async function runStep(args) {
3177
+ const { step } = args;
3178
+ try {
3179
+ if (step.type === "sendText") {
3180
+ args.session.sendText(step.text, { enter: step.enter });
3181
+ return args.last;
3182
+ }
3183
+ if (step.type === "pressKey") {
3184
+ args.session.pressKey(step.key);
3185
+ return args.last;
3186
+ }
3187
+ if (step.type === "sendMouse") {
3188
+ runMouseStep(args.session, step);
3189
+ return args.last;
3190
+ }
3191
+ if (step.type === "resize") {
3192
+ args.session.resize(step.cols, step.rows);
3193
+ return args.last;
3194
+ }
3195
+ if (step.type === "mark") {
3196
+ args.session.mark(step.label);
3197
+ return args.last;
3198
+ }
3199
+ if (step.type === "sleep") {
3200
+ await sleep(step.ms);
3201
+ return args.last;
3202
+ }
3203
+ if (step.type === "waitForText") {
3204
+ await waitForTextStep(args.session, step, args.stepIndex);
3205
+ return args.last;
3206
+ }
3207
+ if (step.type === "waitForStableScreen") {
3208
+ await waitForStableScreenStep(args.session, step, args.stepIndex);
3209
+ return args.last;
3210
+ }
3211
+ if (step.type === "waitForExit") {
3212
+ await waitForExitStep(args.session, step);
3213
+ return args.last;
3214
+ }
3215
+ if (step.type === "expectMeta") {
3216
+ await expectSessionMeta(args.session, step);
3217
+ return args.last;
3218
+ }
3219
+ if (step.type === "snapshot") {
3220
+ const record = await snapshotStep(args.session, step);
3221
+ persistSnapshotRecord({
3222
+ record,
3223
+ saveAs: step.saveAs,
3224
+ saveTo: step.saveTo,
3225
+ snapshots: args.snapshots,
3226
+ resolveArtifactPath: args.resolveArtifactPath
3227
+ });
3228
+ return record;
3229
+ }
3230
+ if (step.type === "expect") {
3231
+ assertRecordMatches(selectSnapshot(args.last, args.snapshots, step.from), step, args.stepIndex);
3232
+ return args.last;
3233
+ }
3234
+ if (step.type === "assert") {
3235
+ await runAssertStep(args.session, step, args.stepIndex);
3236
+ return args.last;
3237
+ }
3238
+ if (step.type === "assertSemantic") return args.last;
3239
+ if (step.type === "expectGolden") {
3240
+ const record = selectSnapshot(args.last, args.snapshots, step.from);
3241
+ assertGoldenText(args.resolveGoldenPath(step.path), `${record.text}\n`, args.updateGoldens);
3242
+ return args.last;
3243
+ }
3244
+ if (step.type === "custom") return await runCustomStep({
3245
+ step,
3246
+ stepIndex: args.stepIndex,
3247
+ session: args.session,
3248
+ snapshots: args.snapshots,
3249
+ last: args.last,
3250
+ resolveGoldenPath: args.resolveGoldenPath,
3251
+ resolveArtifactPath: args.resolveArtifactPath,
3252
+ updateGoldens: args.updateGoldens,
3253
+ stepHandlers: args.stepHandlers,
3254
+ artifactsDir: args.artifactsDir
2414
3255
  });
2415
- return;
3256
+ throw new Error(`unknown type: ${step.type}`);
3257
+ } catch (error) {
3258
+ throw annotateStepError(error, args.stepIndex, step);
2416
3259
  }
2417
- if (!value.frame && !value.frames?.length && !value.framePath && !value.frameModule) ctx.addIssue({
2418
- code: z.ZodIssueCode.custom,
2419
- message: "framework launch requires one of frame, frames, framePath, or frameModule when backend is not pty"
2420
- });
2421
- });
2422
- const scriptTraceSchema = z.object({
2423
- saveCast: z.boolean().optional(),
2424
- saveReport: z.boolean().optional(),
2425
- castPath: z.string().optional(),
2426
- reportPath: z.string().optional(),
2427
- reportScope: z.enum(["visible", "buffer"]).optional(),
2428
- reportMaxFrames: z.number().int().positive().optional()
2429
- });
2430
- const sendTextStepSchema = z.object({
2431
- type: z.literal("sendText"),
2432
- text: z.string(),
2433
- enter: z.boolean().optional()
2434
- });
2435
- const pressKeyStepSchema = z.object({
2436
- type: z.literal("pressKey"),
2437
- key: z.string().min(1)
2438
- });
2439
- const sendMouseStepSchema = z.object({
2440
- type: z.literal("sendMouse"),
2441
- action: z.enum([
2442
- "down",
2443
- "up",
2444
- "move",
2445
- "click",
2446
- "scroll_up",
2447
- "scroll_down"
2448
- ]),
2449
- x: z.number().int(),
2450
- y: z.number().int(),
2451
- button: z.enum([
2452
- "left",
2453
- "middle",
2454
- "right"
2455
- ]).optional(),
2456
- shift: z.boolean().optional(),
2457
- alt: z.boolean().optional(),
2458
- ctrl: z.boolean().optional()
2459
- });
2460
- const resizeStepSchema = z.object({
2461
- type: z.literal("resize"),
2462
- cols: z.number().int().positive(),
2463
- rows: z.number().int().positive()
2464
- });
2465
- const markStepSchema = z.object({
2466
- type: z.literal("mark"),
2467
- label: z.string().optional()
2468
- });
2469
- const sleepStepSchema = z.object({
2470
- type: z.literal("sleep"),
2471
- ms: z.number().int().nonnegative()
2472
- });
2473
- const waitForTextStepSchema = z.object({
2474
- type: z.literal("waitForText"),
2475
- scope: z.enum(["visible", "buffer"]).optional(),
2476
- text: z.string().optional(),
2477
- regex: z.string().optional(),
2478
- timeoutMs: z.number().int().positive().optional(),
2479
- intervalMs: z.number().int().positive().optional()
2480
- }).superRefine((value, ctx) => {
2481
- if (!value.text && !value.regex) ctx.addIssue({
2482
- code: z.ZodIssueCode.custom,
2483
- message: "waitForText requires text or regex"
2484
- });
2485
- });
2486
- const waitForStableScreenStepSchema = z.object({
2487
- type: z.literal("waitForStableScreen"),
2488
- timeoutMs: z.number().int().positive().optional(),
2489
- quietMs: z.number().int().positive().optional(),
2490
- intervalMs: z.number().int().positive().optional()
2491
- });
2492
- const waitForExitStepSchema = z.object({
2493
- type: z.literal("waitForExit"),
2494
- timeoutMs: z.number().int().positive().optional(),
2495
- intervalMs: z.number().int().positive().optional(),
2496
- exitCode: z.number().int().optional(),
2497
- signal: z.union([z.number().int(), z.string()]).optional()
2498
- });
2499
- const expectMetaStepSchema = z.object({
2500
- type: z.literal("expectMeta"),
2501
- bufferType: z.enum(["normal", "alternate"]).optional(),
2502
- cols: z.number().int().positive().optional(),
2503
- rows: z.number().int().positive().optional(),
2504
- cursor: z.object({
2505
- x: z.number().int().positive(),
2506
- y: z.number().int().positive()
2507
- }).optional()
2508
- }).superRefine((value, ctx) => {
2509
- if (value.bufferType === void 0 && value.cols === void 0 && value.rows === void 0 && value.cursor === void 0) ctx.addIssue({
2510
- code: z.ZodIssueCode.custom,
2511
- message: "expectMeta requires at least one assertion (bufferType/cols/rows/cursor)"
2512
- });
2513
- });
2514
- const snapshotStepSchema = z.object({
2515
- type: z.literal("snapshot"),
2516
- kind: z.enum([
2517
- "text",
2518
- "view",
2519
- "ansi",
2520
- "view_ansi",
2521
- "grid"
2522
- ]),
2523
- scope: z.enum(["visible", "buffer"]).optional(),
2524
- trimRight: z.boolean().optional(),
2525
- trimBottom: z.boolean().optional(),
2526
- maxLines: z.number().int().positive().optional(),
2527
- tailLines: z.number().int().positive().optional(),
2528
- lineNumbers: z.boolean().optional(),
2529
- includeStyles: z.boolean().optional(),
2530
- mask: z.array(textMaskRuleSchema).optional(),
2531
- saveAs: z.string().optional(),
2532
- saveTo: z.string().optional()
2533
- }).superRefine((value, ctx) => {
2534
- if (value.maxLines !== void 0 && value.tailLines !== void 0) ctx.addIssue({
2535
- code: z.ZodIssueCode.custom,
2536
- message: "snapshot: maxLines and tailLines are mutually exclusive"
2537
- });
2538
- });
2539
- const expectStepSchema = z.object({
2540
- type: z.literal("expect"),
2541
- from: z.string().optional(),
2542
- equals: z.string().optional(),
2543
- contains: z.array(z.string()).optional(),
2544
- notContains: z.array(z.string()).optional(),
2545
- regex: z.string().optional()
2546
- }).superRefine((value, ctx) => {
2547
- if (value.equals === void 0 && !value.contains?.length && !value.notContains?.length && !value.regex) ctx.addIssue({
2548
- code: z.ZodIssueCode.custom,
2549
- message: "expect requires at least one matcher (equals/contains/notContains/regex)"
2550
- });
2551
- });
2552
- const expectGoldenStepSchema = z.object({
2553
- type: z.literal("expectGolden"),
2554
- from: z.string().optional(),
2555
- path: z.string().min(1)
2556
- });
2557
- const customStepSchema = z.object({
2558
- type: z.literal("custom"),
2559
- name: z.string().min(1),
2560
- payload: z.unknown().optional()
2561
- });
2562
- const assertStepSchema = z.object({
2563
- type: z.literal("assert"),
2564
- scope: z.enum(["visible", "buffer"]).optional(),
2565
- text: z.string().optional(),
2566
- regex: z.string().optional(),
2567
- description: z.string().optional()
2568
- }).superRefine((value, ctx) => {
2569
- if (!value.text && !value.regex) ctx.addIssue({
2570
- code: z.ZodIssueCode.custom,
2571
- message: "assert requires text or regex"
2572
- });
2573
- });
2574
- const assertSemanticStepSchema = z.object({
2575
- type: z.literal("assertSemantic"),
2576
- prompt: z.string().min(1),
2577
- description: z.string().optional()
2578
- });
2579
- const scriptStepSchema = z.union([
2580
- sendTextStepSchema,
2581
- pressKeyStepSchema,
2582
- sendMouseStepSchema,
2583
- resizeStepSchema,
2584
- markStepSchema,
2585
- sleepStepSchema,
2586
- waitForTextStepSchema,
2587
- waitForStableScreenStepSchema,
2588
- waitForExitStepSchema,
2589
- expectMetaStepSchema,
2590
- snapshotStepSchema,
2591
- expectStepSchema,
2592
- expectGoldenStepSchema,
2593
- customStepSchema,
2594
- assertStepSchema,
2595
- assertSemanticStepSchema
2596
- ]);
2597
- const scriptSchema = z.object({
2598
- name: z.string().optional(),
2599
- artifactsDir: z.string().optional(),
2600
- launch: launchConfigSchema,
2601
- trace: scriptTraceSchema.optional(),
2602
- steps: z.array(scriptStepSchema).min(1)
2603
- });
3260
+ }
3261
+ function annotateStepError(error, stepIndex, step) {
3262
+ const label = step.type === "custom" ? `custom(${step.name})` : step.type;
3263
+ const prefix = `step ${stepIndex + 1} ${label}`;
3264
+ if (error instanceof Error) {
3265
+ if (!error.message.startsWith("step ")) error.message = `${prefix}: ${error.message}`;
3266
+ return error;
3267
+ }
3268
+ return /* @__PURE__ */ new Error(`${prefix}: ${String(error)}`);
3269
+ }
2604
3270
  //#endregion
2605
3271
  //#region src/script/runner.ts
2606
3272
  async function runScriptFile(scriptPath, options) {
2607
- const raw = await Bun.file(scriptPath).text();
2608
- const parsedJson = JSON.parse(raw);
2609
- const baseName = basename(scriptPath, extname(scriptPath));
2610
- return runScript(parsedJson && typeof parsedJson === "object" && !Array.isArray(parsedJson) && !("name" in parsedJson) ? {
2611
- ...parsedJson,
2612
- name: baseName
2613
- } : parsedJson, options);
3273
+ return runScript(await loadJsonScriptFileWithDefaultName(scriptPath), options);
2614
3274
  }
2615
3275
  async function runScript(script, options) {
2616
3276
  const parsed = scriptSchema.parse(script);
2617
3277
  const scriptName = parsed.name ?? "script";
2618
3278
  const artifactsDir = resolveArtifactsDir(parsed, scriptName, options?.artifactsDir);
2619
3279
  mkdirSync(artifactsDir, { recursive: true });
2620
- const launch = parsed.launch;
2621
- const cwd = launch.cwd ? resolve(process.cwd(), launch.cwd) : process.cwd();
2622
- const backend = launch.backend ?? "pty";
2623
- let sessions = null;
2624
- let session;
2625
- if (backend === "pty") {
2626
- sessions = new SessionManager({ snapshotRingSize: 50 });
2627
- if (!launch.command) throw new Error("launch.command is required when backend=pty");
2628
- session = sessions.launchSession({
2629
- command: launch.command,
2630
- args: launch.args ?? [],
2631
- cwd,
2632
- env: launch.env,
2633
- cols: launch.cols,
2634
- rows: launch.rows,
2635
- name: launch.name
2636
- });
2637
- } else session = await createFrameSessionFromLaunch({
2638
- launch,
2639
- cwd,
2640
- title: scriptName
3280
+ const { session, closeSession } = await launchScriptSession({
3281
+ launch: parsed.launch,
3282
+ scriptName
2641
3283
  });
2642
- const closeSession = () => {
2643
- if (sessions) {
2644
- sessions.closeAll();
2645
- return;
2646
- }
2647
- session.close();
2648
- };
2649
3284
  const snapshots = /* @__PURE__ */ new Map();
2650
3285
  let last = null;
2651
3286
  let currentStepIndex = -1;
2652
3287
  let currentStep = null;
2653
- const resolveGoldenPath = (path) => isAbsolute(path) ? path : resolve(process.cwd(), path);
2654
- const resolveArtifactPath = (path) => isAbsolute(path) ? path : resolve(artifactsDir, path);
2655
- const trace = parsed.trace ?? {};
2656
- const saveCast = trace.saveCast ?? true;
2657
- const saveReport = trace.saveReport ?? true;
2658
- const castPath = resolveArtifactPath(trace.castPath ?? `${scriptName}.cast`);
2659
- const reportPath = resolveArtifactPath(trace.reportPath ?? `${scriptName}.report.html`);
3288
+ const { resolveArtifactPath, resolveGoldenPath } = createScriptPathResolvers(artifactsDir);
3289
+ const traceArtifacts = resolveScriptTraceArtifacts({
3290
+ script: parsed,
3291
+ scriptName,
3292
+ resolveArtifactPath
3293
+ });
2660
3294
  const stepHandlers = options?.steps;
2661
3295
  const executionSteps = [];
2662
3296
  try {
@@ -2679,30 +3313,9 @@ async function runScript(script, options) {
2679
3313
  stepHandlers,
2680
3314
  artifactsDir
2681
3315
  });
2682
- let after = last;
2683
- if (!(last !== before)) try {
2684
- const captured = await session.snapshotText({
2685
- scope: "visible",
2686
- trimRight: true,
2687
- trimBottom: true,
2688
- captureFrame: true
2689
- });
2690
- const lines = captured.text.split("\n");
2691
- const view = formatSnapshotView({
2692
- sessionId: session.id,
2693
- scope: "visible",
2694
- hash: captured.hash,
2695
- lines,
2696
- meta: session.getMeta(),
2697
- lineNumbers: true
2698
- });
2699
- after = {
2700
- kind: "view",
2701
- hash: captured.hash,
2702
- text: view
2703
- };
2704
- last = after;
2705
- } catch {}
3316
+ const stepProducedNewSnapshot = last !== before;
3317
+ const after = stepProducedNewSnapshot ? last : await snapshotAfterStep(session);
3318
+ if (!stepProducedNewSnapshot && after) last = after;
2706
3319
  executionSteps.push({
2707
3320
  index: stepIndex,
2708
3321
  step,
@@ -2727,12 +3340,12 @@ async function runScript(script, options) {
2727
3340
  await writeTraceArtifacts({
2728
3341
  session,
2729
3342
  artifactsDir,
2730
- saveCast,
2731
- castPath,
2732
- saveReport,
2733
- reportPath,
2734
- reportScope: trace.reportScope,
2735
- reportMaxFrames: trace.reportMaxFrames,
3343
+ saveCast: traceArtifacts.saveCast,
3344
+ castPath: traceArtifacts.castPath,
3345
+ saveReport: traceArtifacts.saveReport,
3346
+ reportPath: traceArtifacts.reportPath,
3347
+ reportScope: traceArtifacts.reportScope,
3348
+ reportMaxFrames: traceArtifacts.reportMaxFrames,
2736
3349
  scriptName,
2737
3350
  result: { ok: true },
2738
3351
  executionSteps
@@ -2771,12 +3384,12 @@ async function runScript(script, options) {
2771
3384
  await writeTraceArtifacts({
2772
3385
  session,
2773
3386
  artifactsDir,
2774
- saveCast,
2775
- castPath,
2776
- saveReport,
2777
- reportPath,
2778
- reportScope: trace.reportScope,
2779
- reportMaxFrames: trace.reportMaxFrames,
3387
+ saveCast: traceArtifacts.saveCast,
3388
+ castPath: traceArtifacts.castPath,
3389
+ saveReport: traceArtifacts.saveReport,
3390
+ reportPath: traceArtifacts.reportPath,
3391
+ reportScope: traceArtifacts.reportScope,
3392
+ reportMaxFrames: traceArtifacts.reportMaxFrames,
2780
3393
  scriptName,
2781
3394
  result: {
2782
3395
  ok: false,
@@ -2794,464 +3407,5 @@ async function runScript(script, options) {
2794
3407
  throw error;
2795
3408
  }
2796
3409
  }
2797
- function resolveArtifactsDir(script, scriptName, override) {
2798
- if (override?.trim()) return resolve(override.trim());
2799
- if (script.artifactsDir?.trim()) return resolve(script.artifactsDir.trim());
2800
- return resolve(".tmp", "runs", scriptName);
2801
- }
2802
- async function runStep(args) {
2803
- const { step } = args;
2804
- try {
2805
- if (step.type === "sendText") {
2806
- args.session.sendText(step.text, { enter: step.enter });
2807
- return args.last;
2808
- }
2809
- if (step.type === "pressKey") {
2810
- args.session.pressKey(step.key);
2811
- return args.last;
2812
- }
2813
- if (step.type === "sendMouse") {
2814
- const modifiers = step.shift || step.alt || step.ctrl ? {
2815
- shift: step.shift,
2816
- alt: step.alt,
2817
- ctrl: step.ctrl
2818
- } : void 0;
2819
- args.session.sendMouse({
2820
- action: step.action,
2821
- x: step.x,
2822
- y: step.y,
2823
- button: step.button,
2824
- modifiers
2825
- });
2826
- return args.last;
2827
- }
2828
- if (step.type === "resize") {
2829
- args.session.resize(step.cols, step.rows);
2830
- return args.last;
2831
- }
2832
- if (step.type === "mark") {
2833
- args.session.mark(step.label);
2834
- return args.last;
2835
- }
2836
- if (step.type === "sleep") {
2837
- await sleep(step.ms);
2838
- return args.last;
2839
- }
2840
- if (step.type === "waitForText") {
2841
- const regex = step.regex ? new RegExp(step.regex) : void 0;
2842
- if (!(await args.session.waitForText({
2843
- scope: step.scope,
2844
- text: step.text,
2845
- regex,
2846
- timeoutMs: step.timeoutMs ?? 1e4,
2847
- intervalMs: step.intervalMs ?? 100
2848
- })).found) throw new Error(`step ${args.stepIndex + 1} waitForText not found: ${step.text ?? step.regex ?? ""}`);
2849
- return args.last;
2850
- }
2851
- if (step.type === "waitForStableScreen") {
2852
- if (!(await args.session.waitForStableScreen({
2853
- timeoutMs: step.timeoutMs ?? 1e4,
2854
- quietMs: step.quietMs ?? 400,
2855
- intervalMs: step.intervalMs ?? 80
2856
- })).stable) throw new Error(`step ${args.stepIndex + 1} waitForStableScreen timed out`);
2857
- return args.last;
2858
- }
2859
- if (step.type === "waitForExit") {
2860
- const startedAt = Date.now();
2861
- const timeoutMs = step.timeoutMs ?? 1e4;
2862
- const intervalMs = step.intervalMs ?? 50;
2863
- while (Date.now() - startedAt <= timeoutMs) {
2864
- if (args.session.isClosed()) break;
2865
- await sleep(intervalMs);
2866
- }
2867
- const reason = args.session.getCloseReason();
2868
- if (!reason) throw new Error("waitForExit timed out");
2869
- if (reason.type !== "process_exit") throw new Error("waitForExit: session was closed by user");
2870
- if (step.exitCode !== void 0 && reason.exitCode !== step.exitCode) throw new Error(`waitForExit: exitCode mismatch (got ${reason.exitCode})`);
2871
- if (step.signal !== void 0 && reason.signal !== step.signal) throw new Error(`waitForExit: signal mismatch (got ${String(reason.signal ?? "")})`);
2872
- return args.last;
2873
- }
2874
- if (step.type === "expectMeta") {
2875
- await args.session.flush();
2876
- const meta = args.session.getMeta();
2877
- if (step.bufferType !== void 0 && meta.bufferType !== step.bufferType) throw new Error(`expectMeta.bufferType mismatch (got ${meta.bufferType})`);
2878
- if (step.cols !== void 0 && meta.cols !== step.cols) throw new Error(`expectMeta.cols mismatch (got ${meta.cols})`);
2879
- if (step.rows !== void 0 && meta.rows !== step.rows) throw new Error(`expectMeta.rows mismatch (got ${meta.rows})`);
2880
- if (step.cursor) {
2881
- const cursorViewportRow = meta.baseY + meta.cursorY - meta.viewportY;
2882
- const actual = {
2883
- x: meta.cursorX + 1,
2884
- y: cursorViewportRow + 1
2885
- };
2886
- if (actual.x !== step.cursor.x || actual.y !== step.cursor.y) throw new Error(`expectMeta.cursor mismatch (got ${actual.x},${actual.y})`);
2887
- }
2888
- return args.last;
2889
- }
2890
- if (step.type === "snapshot") {
2891
- const record = await snapshotStep(args.session, step);
2892
- persistSnapshotRecord({
2893
- record,
2894
- saveAs: step.saveAs,
2895
- saveTo: step.saveTo,
2896
- snapshots: args.snapshots,
2897
- resolveArtifactPath: args.resolveArtifactPath
2898
- });
2899
- return record;
2900
- }
2901
- if (step.type === "expect") {
2902
- assertRecordMatches(selectSnapshot(args.last, args.snapshots, step.from), step, args.stepIndex);
2903
- return args.last;
2904
- }
2905
- if (step.type === "assert") {
2906
- const regex = step.regex ? new RegExp(step.regex) : void 0;
2907
- if (!(await args.session.waitForText({
2908
- scope: step.scope,
2909
- text: step.text,
2910
- regex,
2911
- timeoutMs: 0,
2912
- intervalMs: 0
2913
- })).found) throw new Error(`step ${args.stepIndex + 1} assert failed: ${step.description || step.text || step.regex || "pattern mismatch"}`);
2914
- return args.last;
2915
- }
2916
- if (step.type === "assertSemantic") return args.last;
2917
- if (step.type === "expectGolden") {
2918
- const record = selectSnapshot(args.last, args.snapshots, step.from);
2919
- assertGoldenText(args.resolveGoldenPath(step.path), `${record.text}\n`, args.updateGoldens);
2920
- return args.last;
2921
- }
2922
- if (step.type === "custom") {
2923
- const handler = args.stepHandlers?.[step.name];
2924
- if (!handler) throw new Error(`custom handler not found: ${step.name}`);
2925
- const result = await handler({
2926
- session: args.session,
2927
- stepIndex: args.stepIndex,
2928
- last: args.last,
2929
- snapshots: args.snapshots,
2930
- artifactsDir: args.artifactsDir,
2931
- resolveArtifactPath: args.resolveArtifactPath,
2932
- resolveGoldenPath: args.resolveGoldenPath,
2933
- updateGoldens: args.updateGoldens,
2934
- captureSnapshot: async (snapshotConfig) => {
2935
- const record = await snapshotStep(args.session, {
2936
- type: "snapshot",
2937
- ...snapshotConfig
2938
- });
2939
- persistSnapshotRecord({
2940
- record,
2941
- saveAs: snapshotConfig.saveAs,
2942
- saveTo: snapshotConfig.saveTo,
2943
- snapshots: args.snapshots,
2944
- resolveArtifactPath: args.resolveArtifactPath
2945
- });
2946
- return record;
2947
- },
2948
- getSnapshot: (from) => selectSnapshot(args.last, args.snapshots, from),
2949
- writeArtifactText: (path, text) => {
2950
- const resolved = args.resolveArtifactPath(path);
2951
- mkdirSync(dirname(resolved), { recursive: true });
2952
- writeFileSync(resolved, text, "utf8");
2953
- },
2954
- assertGoldenText: (path, text) => {
2955
- assertGoldenText(args.resolveGoldenPath(path), text, args.updateGoldens);
2956
- }
2957
- }, step);
2958
- if (!result) return args.last;
2959
- return result;
2960
- }
2961
- throw new Error(`unknown type: ${step.type}`);
2962
- } catch (error) {
2963
- throw annotateStepError(error, args.stepIndex, step);
2964
- }
2965
- }
2966
- function persistSnapshotRecord(args) {
2967
- const saveAs = args.saveAs?.trim();
2968
- if (saveAs) args.snapshots.set(saveAs, args.record);
2969
- const saveTo = args.saveTo?.trim();
2970
- if (!saveTo) return;
2971
- const path = args.resolveArtifactPath(saveTo);
2972
- mkdirSync(dirname(path), { recursive: true });
2973
- writeFileSync(path, `${args.record.text}\n`, "utf8");
2974
- }
2975
- function annotateStepError(error, stepIndex, step) {
2976
- const label = step.type === "custom" ? `custom(${step.name})` : step.type;
2977
- const prefix = `step ${stepIndex + 1} ${label}`;
2978
- if (error instanceof Error) {
2979
- if (!error.message.startsWith("step ")) error.message = `${prefix}: ${error.message}`;
2980
- return error;
2981
- }
2982
- return /* @__PURE__ */ new Error(`${prefix}: ${String(error)}`);
2983
- }
2984
- function selectSnapshot(last, snapshots, from) {
2985
- const key = from?.trim() ? from.trim() : "last";
2986
- if (key === "last") {
2987
- if (!last) throw new Error("expect: no previous snapshot (from=last)");
2988
- return last;
2989
- }
2990
- const found = snapshots.get(key);
2991
- if (!found) throw new Error(`expect: unknown snapshot reference: ${key}`);
2992
- return found;
2993
- }
2994
- function assertRecordMatches(record, step, stepIndex) {
2995
- if (step.equals !== void 0 && record.text !== step.equals) throw new Error(`step ${stepIndex + 1} expect.equals failed`);
2996
- if (step.contains && step.contains.length > 0) {
2997
- for (const item of step.contains) if (!record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.contains failed: ${JSON.stringify(item)}`);
2998
- }
2999
- if (step.notContains && step.notContains.length > 0) {
3000
- for (const item of step.notContains) if (record.text.includes(item)) throw new Error(`step ${stepIndex + 1} expect.notContains failed: ${JSON.stringify(item)}`);
3001
- }
3002
- if (step.regex) {
3003
- if (!new RegExp(step.regex).test(record.text)) throw new Error(`step ${stepIndex + 1} expect.regex failed: ${JSON.stringify(step.regex)}`);
3004
- }
3005
- }
3006
- function assertGoldenText(path, text, update) {
3007
- if (update) {
3008
- mkdirSync(dirname(path), { recursive: true });
3009
- writeFileSync(path, text, "utf8");
3010
- return;
3011
- }
3012
- if (text !== readFileSync(path, "utf8")) throw new Error(`golden mismatch: ${path}`);
3013
- }
3014
- async function writeFailureArtifacts(args) {
3015
- const { session, artifactsDir, scriptName, stepIndex, step, last, error } = args;
3016
- const err = error instanceof Error ? error : new Error(String(error));
3017
- const errorText = err.stack ?? err.message;
3018
- writeFileSync(join(artifactsDir, "failure.error.txt"), `${errorText}\n`, "utf8");
3019
- const stepPayload = {
3020
- script: scriptName,
3021
- stepIndex: stepIndex >= 0 ? stepIndex + 1 : null,
3022
- step: step ?? null,
3023
- last: last ? {
3024
- kind: last.kind,
3025
- hash: last.hash
3026
- } : null
3027
- };
3028
- writeFileSync(join(artifactsDir, "failure.step.json"), `${JSON.stringify(stepPayload, null, 2)}\n`, "utf8");
3029
- let capturedText = void 0;
3030
- let capturedHash = void 0;
3031
- try {
3032
- const captured = await session.snapshotText({
3033
- scope: "visible",
3034
- trimRight: true,
3035
- trimBottom: true,
3036
- captureFrame: true
3037
- });
3038
- capturedText = captured.text;
3039
- capturedHash = captured.hash;
3040
- } catch {}
3041
- const text = capturedText ?? last?.text;
3042
- const hash = capturedHash ?? last?.hash ?? "unknown";
3043
- if (text !== void 0) {
3044
- writeFileSync(join(artifactsDir, "failure.last.txt"), `${text}\n`, "utf8");
3045
- const view = formatSnapshotView({
3046
- sessionId: session.id,
3047
- scope: "visible",
3048
- hash,
3049
- lines: text.split("\n"),
3050
- meta: session.getMeta(),
3051
- lineNumbers: true
3052
- });
3053
- writeFileSync(join(artifactsDir, "failure.last.view.txt"), `${view}\n`, "utf8");
3054
- }
3055
- }
3056
- async function snapshotStep(session, step) {
3057
- if (step.kind === "grid") {
3058
- if (step.mask && step.mask.length > 0) throw new Error("snapshot.kind=grid does not support mask (use text/view instead)");
3059
- const { grid, hash } = await session.snapshotGrid({
3060
- trimRight: step.trimRight,
3061
- includeStyles: step.includeStyles,
3062
- captureFrame: true
3063
- });
3064
- return {
3065
- kind: step.kind,
3066
- hash,
3067
- text: JSON.stringify(grid, null, 2)
3068
- };
3069
- }
3070
- if (step.kind === "ansi" || step.kind === "view_ansi") {
3071
- const { ansi, hash } = await session.snapshotAnsi({
3072
- scope: step.scope,
3073
- trimRight: step.trimRight,
3074
- trimBottom: step.trimBottom ?? true,
3075
- maxLines: step.maxLines,
3076
- tailLines: step.tailLines,
3077
- mask: step.mask
3078
- });
3079
- if (step.kind === "ansi") return {
3080
- kind: step.kind,
3081
- hash,
3082
- text: ansi
3083
- };
3084
- const lines = ansi.split("\n");
3085
- const view = formatSnapshotView({
3086
- sessionId: session.id,
3087
- scope: step.scope ?? "visible",
3088
- hash,
3089
- lines,
3090
- meta: session.getMeta(),
3091
- lineNumbers: step.lineNumbers
3092
- });
3093
- return {
3094
- kind: step.kind,
3095
- hash,
3096
- text: view
3097
- };
3098
- }
3099
- const { text, hash } = await session.snapshotText({
3100
- scope: step.scope,
3101
- trimRight: step.trimRight,
3102
- trimBottom: step.trimBottom ?? true,
3103
- maxLines: step.maxLines,
3104
- tailLines: step.tailLines,
3105
- captureFrame: true,
3106
- mask: step.mask
3107
- });
3108
- if (step.kind === "text") return {
3109
- kind: step.kind,
3110
- hash,
3111
- text
3112
- };
3113
- const lines = text.split("\n");
3114
- const view = formatSnapshotView({
3115
- sessionId: session.id,
3116
- scope: step.scope ?? "visible",
3117
- hash,
3118
- lines,
3119
- meta: session.getMeta(),
3120
- lineNumbers: step.lineNumbers
3121
- });
3122
- return {
3123
- kind: step.kind,
3124
- hash,
3125
- text: view
3126
- };
3127
- }
3128
- async function writeTraceArtifacts(args) {
3129
- if (!args.saveCast && !args.saveReport) return;
3130
- const snapshot = await args.session.snapshotCast();
3131
- if (args.saveCast) {
3132
- mkdirSync(dirname(args.castPath), { recursive: true });
3133
- writeFileSync(args.castPath, snapshot.cast, "utf8");
3134
- }
3135
- if (args.saveReport) {
3136
- const artifactHrefs = buildReportArtifactHrefs({
3137
- reportPath: args.reportPath,
3138
- castPath: args.saveCast ? args.castPath : null,
3139
- artifactsDir: args.artifactsDir,
3140
- includeFailures: args.result?.ok === false
3141
- });
3142
- const html = await generateTraceReportHtml(snapshot.cast, {
3143
- scope: args.reportScope,
3144
- maxFrames: args.reportMaxFrames,
3145
- scriptName: args.scriptName,
3146
- result: args.result,
3147
- artifacts: artifactHrefs,
3148
- steps: args.executionSteps
3149
- });
3150
- mkdirSync(dirname(args.reportPath), { recursive: true });
3151
- writeFileSync(args.reportPath, html, "utf8");
3152
- ensureAsciinemaPlayerAssets(args.reportPath);
3153
- }
3154
- }
3155
- function buildReportArtifactHrefs(args) {
3156
- const items = {};
3157
- if (args.castPath) items.castHref = relativeHref(args.reportPath, args.castPath);
3158
- if (args.includeFailures) {
3159
- items.failureErrorHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.error.txt"));
3160
- items.failureStepHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.step.json"));
3161
- items.failureLastTextHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.txt"));
3162
- items.failureLastViewHref = relativeHref(args.reportPath, join(args.artifactsDir, "failure.last.view.txt"));
3163
- }
3164
- return Object.keys(items).length ? items : void 0;
3165
- }
3166
- function relativeHref(fromFile, toFile) {
3167
- const normalized = relative(dirname(fromFile), toFile).replace(/\\/g, "/");
3168
- return normalized.startsWith(".") ? normalized : `./${normalized}`;
3169
- }
3170
- function formatStepLabel(step) {
3171
- return step.type === "custom" ? `custom(${step.name})` : step.type;
3172
- }
3173
- function formatPublicStepLabel(step) {
3174
- const showText = envTruthy(process.env.PTYWRIGHT_REPORT_SHOW_STEP_TEXT);
3175
- if (step.type === "custom") return `custom(${step.name})`;
3176
- if (step.type === "sendText") {
3177
- const enter = step.enter !== void 0 ? ` enter=${String(step.enter)}` : "";
3178
- if (!showText) return `sendText <redacted> (len=${step.text.length}${enter})`;
3179
- return `sendText "${truncateInline(step.text)}"${enter ? ` (${enter.trim()})` : ""}`;
3180
- }
3181
- if (step.type === "pressKey") return `pressKey ${step.key}`;
3182
- if (step.type === "sendMouse") return `sendMouse ${step.action} (${step.x},${step.y})`;
3183
- if (step.type === "resize") return `resize ${step.cols}x${step.rows}`;
3184
- if (step.type === "mark") return step.label ? `mark ${step.label}` : "mark";
3185
- if (step.type === "sleep") return `sleep ${step.ms}ms`;
3186
- if (step.type === "waitForText") {
3187
- if (!showText) return step.text ? "waitForText (text)" : step.regex ? "waitForText (regex)" : "waitForText";
3188
- if (step.text) return `waitFor "${truncateInline(step.text)}"`;
3189
- if (step.regex) return `waitFor /${truncateInline(step.regex)}/`;
3190
- return "waitForText";
3191
- }
3192
- if (step.type === "waitForStableScreen") return "waitForStableScreen";
3193
- if (step.type === "waitForExit") return "waitForExit";
3194
- if (step.type === "expectMeta") return "expectMeta";
3195
- if (step.type === "snapshot") return `snapshot ${step.kind}${step.saveAs ? ` as ${step.saveAs}` : ""}`;
3196
- if (step.type === "expect") {
3197
- const parts = [];
3198
- if (step.equals !== void 0) parts.push("equals");
3199
- if (step.contains?.length) parts.push(`contains(${step.contains.length})`);
3200
- if (step.notContains?.length) parts.push(`notContains(${step.notContains.length})`);
3201
- if (step.regex) parts.push("regex");
3202
- return parts.length ? `expect ${parts.join(",")}` : "expect";
3203
- }
3204
- if (step.type === "expectGolden") return `expectGolden ${step.path}`;
3205
- if (step.type === "assert") {
3206
- if (!showText) return step.text ? "assert (text)" : step.regex ? "assert (regex)" : "assert";
3207
- if (step.text) return `assert "${truncateInline(step.text)}"`;
3208
- if (step.regex) return `assert /${truncateInline(step.regex)}/`;
3209
- if (step.description) return `assert "${truncateInline(step.description)}"`;
3210
- return "assert";
3211
- }
3212
- if (step.type === "assertSemantic") return "assertSemantic";
3213
- return assertUnreachableStep(step);
3214
- }
3215
- function truncateInline(text, maxChars = 60) {
3216
- const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\\n");
3217
- if (normalized.length <= maxChars) return normalized;
3218
- return `${normalized.slice(0, maxChars)}…(+${normalized.length - maxChars})`;
3219
- }
3220
- function assertUnreachableStep(_step) {
3221
- return "unknown";
3222
- }
3223
- function writeTestDataArtifact(args) {
3224
- try {
3225
- const testId = basename(args.artifactsDir);
3226
- const outPath = args.resolveArtifactPath("test.data.js");
3227
- mkdirSync(dirname(outPath), { recursive: true });
3228
- const steps = args.executionSteps.map((s) => ({
3229
- index: s.index + 1,
3230
- type: s.step.type,
3231
- label: formatPublicStepLabel(s.step),
3232
- ok: s.ok,
3233
- durationMs: s.durationMs,
3234
- error: s.ok ? null : s.error ?? null
3235
- }));
3236
- const data = {
3237
- version: 1,
3238
- testId,
3239
- scriptName: args.scriptName,
3240
- ok: args.ok,
3241
- error: args.ok ? null : args.error ?? null,
3242
- stepCount: steps.length,
3243
- steps,
3244
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
3245
- };
3246
- const json = JSON.stringify(data).replaceAll("<", "\\u003c");
3247
- writeFileSync(outPath, `globalThis.__ptywright = globalThis.__ptywright || {};
3248
- globalThis.__ptywright.tests = globalThis.__ptywright.tests || {};
3249
- globalThis.__ptywright.tests[${JSON.stringify(testId)}] = ${json};\n`, "utf8");
3250
- } catch {}
3251
- }
3252
- function envTruthy(value) {
3253
- const v = value?.trim().toLowerCase();
3254
- return v === "1" || v === "true" || v === "yes" || v === "on";
3255
- }
3256
3410
  //#endregion
3257
- export { ensureAsciinemaPlayerAssets as a, createDefaultPtyAdapter as c, generateTraceReportHtml as i, resolvePtyBackend as l, runScriptFile as n, formatSnapshotView as o, scriptSchema as r, SessionManager as s, runScript as t };
3411
+ export { jsonForHtml as a, scriptSchema as c, resolvePtyBackend as d, escapeHtml as i, SessionManager as l, runScriptFile as n, ensureAsciinemaPlayerAssets as o, generateTraceReportHtml as r, formatSnapshotView as s, runScript as t, createDefaultPtyAdapter as u };