| 34 | |
| 35 | /** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */ |
| 36 | const toAsciicast = (recording: Uint8Array): string => { |
| 37 | const events = new TextDecoder() |
| 38 | .decode(recording) |
| 39 | .split("\n") |
| 40 | .filter(Boolean) |
| 41 | .flatMap((line): AsciicastEvent[] => { |
| 42 | // The PTY can be killed mid-write on teardown (e.g. a vitest timeout |
| 43 | // interrupts the fiber), leaving a truncated final JSONL line. A line |
| 44 | // that doesn't parse is one dropped frame, never a failed test: skip it |
| 45 | // instead of throwing. |
| 46 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: lenient parse of a best-effort recording artifact |
| 47 | try { |
| 48 | return [JSON.parse(line) as AsciicastEvent]; |
| 49 | } catch { |
| 50 | return []; |
| 51 | } |
| 52 | }); |
| 53 | const header = events.find((event) => event.type === "header"); |
| 54 | const lines = [ |
| 55 | JSON.stringify({ version: 2, width: header?.cols ?? 80, height: header?.rows ?? 24 }), |
| 56 | ]; |
| 57 | // One streaming decoder so multi-byte UTF-8 split across events survives. |
| 58 | const decoder = new TextDecoder(); |
| 59 | for (const event of events) { |
| 60 | if (event.type !== "output") continue; |
| 61 | const text = decoder.decode(Uint8Array.from(event.bytes ?? []), { stream: true }); |
| 62 | if (text) lines.push(JSON.stringify([(event.at_ms ?? 0) / 1000, "o", text])); |
| 63 | } |
| 64 | return `${lines.join("\n")}\n`; |
| 65 | }; |
| 66 | |
| 67 | // acquireUseRelease so a vitest timeout (fiber interruption) still tears the |
| 68 | // PTY down instead of leaking the child process. |