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