cli-testing

πŸ–ΌοΈ Rendering components

Drive an Ink component in process with renderFrames and read its frame history, not just its last frame

renderFrames(element)

import { renderFrames } from "@kud/cli-testing"

const ui = renderFrames(<DnsList domain="example.com" />)

await ui.waitFor("1 record")
expect(ui.output()).toContain("1.2.3.4")

ui.write("j") // send input, as a user would
ui.unmount()

renderFrames wraps ink-testing-library's render and returns:

method
output()every frame so far, joined β€” what assertions want
lastFrame()the most recent frame alone
waitFor(needle, opts?)resolves with the output once a string or pattern appears
write(input)send keystrokes
unmount()tear down

output() vs lastFrame()

An Ink command that renders, fetches, then exits unmounts itself the moment its data lands β€” and an unmounted component's last frame is empty. Asserting on lastFrame() therefore races the unmount: it fails on exactly the commands that work, and passes on ones that hang, because a hanging command never unmounts and its last frame stays populated.

output() reads the frame history instead β€” every frame ink has ever produced, joined β€” which persists after unmount. It's what assertions want by default. lastFrame() is still exposed, because a test that genuinely wants to know what the user is left looking at should reach for it. It's the default that was wrong, not the capability.

waitFor

export type WaitOptions = {
  /** Milliseconds before giving up. */
  timeout?: number
  /** Milliseconds between checks. */
  interval?: number
}

waitFor(needle, options?) polls output() β€” the history, not the last frame β€” until needle (a string or a RegExp) appears, then resolves with the output at that point. Defaults are timeout: 2_000 and interval: 10.

It's a poll rather than a hook into Ink's render cycle: the frame that satisfies the wait is usually produced by a promise the test never holds a reference to, so there's nothing to await except the output itself.

On timeout it throws with the frames that were drawn:

Timed out after 2000ms waiting for 1 record.
Frames so far:
Loading…

That's usually enough to spot the actual cause β€” the text rendered, just spelled differently than the needle expects β€” without reaching for a debugger.

On this page