recording.test.ts 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import { tmpdir } from "node:os"
  4. import { join } from "node:path"
  5. import { TextRenderable } from "@opentui/core"
  6. import { createHarness, matches } from "../src/frontend/actions"
  7. import { SimulationRenderer } from "../src/frontend/renderer"
  8. import { Effect } from "effect"
  9. import { Timeline, type Event } from "../src/recording"
  10. test("streams ANSI chunks into a versioned JSONL timeline", async () => {
  11. const directory = await mkdtemp(join(tmpdir(), "simulation-recording-"))
  12. const path = join(directory, "nested", "timeline.jsonl")
  13. try {
  14. const timeline = await Timeline.create(path, 80, 24)
  15. await new Promise<void>((resolve, reject) => {
  16. timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve()))
  17. })
  18. timeline.resize(100, 30)
  19. expect(await timeline.finish()).toBe(path)
  20. await new Promise<void>((resolve) => timeline.write(Buffer.from("ignored"), () => resolve()))
  21. const events = (await Bun.file(path).text())
  22. .trim()
  23. .split("\n")
  24. .map((line) => JSON.parse(line) as Event)
  25. expect(events[0]).toEqual({ type: "header", version: 1, cols: 80, rows: 24, encoding: "base64" })
  26. expect(events[1]?.type).toBe("output")
  27. if (events[1]?.type !== "output") throw new Error("Missing output event")
  28. expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello")
  29. expect(events[1].at_ms).toBeGreaterThanOrEqual(0)
  30. expect(events[2]).toMatchObject({ type: "resize", cols: 100, rows: 30 })
  31. expect(events.at(-1)).toMatchObject({ type: "output", data: "" })
  32. } finally {
  33. await rm(directory, { recursive: true, force: true })
  34. }
  35. })
  36. test("captures native renderer output and finishes on destroy", async () => {
  37. const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-"))
  38. const path = join(directory, "timeline.jsonl")
  39. try {
  40. await Effect.runPromise(
  41. Effect.scoped(
  42. Effect.gen(function* () {
  43. const renderer = yield* SimulationRenderer.create({}, path)
  44. yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
  45. renderer.destroy()
  46. expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
  47. const events = (yield* Effect.promise(() => Bun.file(path).text()))
  48. .trim()
  49. .split("\n")
  50. .map((line) => JSON.parse(line) as Event)
  51. expect(events.some((event) => event.type === "output")).toBe(true)
  52. }),
  53. ),
  54. )
  55. } finally {
  56. await rm(directory, { recursive: true, force: true })
  57. }
  58. })
  59. test("matches live screen text while recording", async () => {
  60. const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-"))
  61. const path = join(directory, "timeline.jsonl")
  62. try {
  63. await Effect.runPromise(
  64. Effect.scoped(
  65. Effect.gen(function* () {
  66. const renderer = yield* SimulationRenderer.create({}, path)
  67. renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" }))
  68. yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
  69. expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
  70. }),
  71. ),
  72. )
  73. } finally {
  74. await rm(directory, { recursive: true, force: true })
  75. }
  76. })