Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions .claude/skills/termlens/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ description: Write, fix or review headless terminal tests for a Rust CLI or TUI

# Testing terminal programs with termlens

Written against **termlens 0.10.1**. Every `rust` block below is a complete
integration test that is compiled against the crate in CI, so the API it
shows is the API that exists. The recipes spawn a binary called `myapp`
that draws a list with a `> ` highlight, a status line ending in
Written against **termlens 0.11.0**, the stability candidate: from 0.11.0
no promised public item changes incompatibly before 1.0, so the API below
is one to build on, not one to expect to move. Every `rust` block below is
a complete integration test that is compiled against the crate in CI, so
the API it shows is the API that exists. The recipes spawn a binary called
`myapp` that draws a list with a `> ` highlight, a status line ending in
`Ready: j/k move, q quits`, and prints usage on `--help`; substitute your
application's own texts where the comments say so.

Expand All @@ -22,6 +24,13 @@ screen assertions work and frame assertions do not — `wait_frame`,
`record`, graphics and mouse modes are Unix-only there, and a test that
needs one is `#[cfg_attr(windows, ignore = "…")]` with the reason.

**The API is stable.** 0.11.0 is the stability candidate: the documented
public API in every feature configuration, the snapshot text format, the
JSON shape and the CLI's contract do not change incompatibly before 1.0
(the crate's `docs/STABILITY.md` says exactly what is promised and what
checks it). Write against it as you would against a 1.x crate; do not
pin a patch version or hedge for the next minor.

Use it for the things an in-process mock cannot see:

- raw-mode and alternate-screen entry and exit, and whether the terminal is
Expand Down Expand Up @@ -96,7 +105,8 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─

6. **Two coordinate orders exist; do not mix them.** Everything that
addresses a cell is **row-first**: `find` → `(row, col)`, `cell(row,
col)`, `row_text(row)`, `cursor()` → `(row, col, visible)`. Everything
col)`, `row_text(row)`, `cursor()` → `(row, col, visible)` (and
`cursor_visible()` for the flag alone). Everything
that speaks of terminal geometry or a pointer is **column-first**:
`size()` → `(cols, rows)`, `resize(cols, rows)`, `click(col, row)`,
`scroll(col, row, …)`, `drag(button, from_col, from_row, to_col,
Expand Down Expand Up @@ -154,7 +164,7 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─

```toml
[dev-dependencies]
termlens = "0.10"
termlens = "0.11"
insta = "1" # for the snapshot recipes; termlens also re-exports it as `termlens::insta`
```

Expand Down Expand Up @@ -326,8 +336,7 @@ fn cells_styles_and_wide_characters() -> termlens::Result<()> {
// Regions and the cursor. rect_text is (cols, rows), like size().
let list_pane = s.rect_text(0..20, 0..6);
assert!(list_pane.contains("Gamma"), "{list_pane}");
let (_, _, visible) = s.cursor();
assert!(!visible, "a list view hides the cursor: {s}");
assert!(!s.cursor_visible(), "a list view hides the cursor: {s}");

t.send(termlens::Key::Char('q'))?;
assert!(t.wait_exit()?.success());
Expand Down Expand Up @@ -458,13 +467,14 @@ from_r, to_c, to_r)`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`,
| `full_text()` / `scrollback_text()` / `scrollback_rows()` | history + screen / history / count |
| `scrollback_cell(row, col)` / `styled_scrollback()` | history as cells, with `scrollback_styles(true)` |
| `size()` / `cols()` / `rows()` | `(cols, rows)` |
| `cursor()` | `(row, col, visible)`; `cursor_shape()`, `cursor_blink()` |
| `cursor()` / `cursor_visible()` | `(row, col, visible)` / the flag alone; `cursor_shape()`, `cursor_blink()` |
| `alternate_screen()`, `bracketed_paste()`, `application_cursor()`, `focus_events()` | mode flags |
| `mouse_mode()` / `mouse_modes()` | reporting protocol / the set the app enabled |
| `title()`, `clipboard()`, `links()`, `bells()`, `repaints()`, `graphics()` | out-of-band state |
| `unsupported()` / `insert_mode()` | sequences the emulator did not implement (`^[[20h`…), so a plausible grid can be told from a right one / IRM left on |
| `with_styles()` | `Display` with a `styles:` block; snapshot this to catch colour regressions |
| `diff(&other)` | `ScreenDiff`: `is_empty()`, `cells()`, and a `Display` of only the rows that changed |
| `unsupported()` / `insert_mode()` | an `Unsupported` view of the sequences the emulator did not implement (`^[[20h`…) — `is_empty()`, `contains("^[[5m")`, `iter()`, `overflow()`, and `assert_eq!(s.unsupported(), ["^[[59m"])` pins it — so a plausible grid can be told from a right one / IRM left on |
| `with_styles()` | `ScreenWithStyles`, a `Display` with a `styles:` block; snapshot this to catch colour regressions |
| `diff(&other)` | `ScreenDiff`: `is_empty()`, `cells()`, `changed_rows()`, `style_changes()`, and a `Display` of only the rows that changed |
| `locate(needle)` | `Option<Location>`: `is_on_screen()`, `is_in_history()`, `col()` |
| `mask_rect(cols, rows)` / `mask_matching(literal, fill)` / `mask_cells(pred)` | a new `Screen` with those cells replaced, styles and columns intact. `mask_matching` is a literal (rows included — it spans a wrap the way `find_all` does); `mask_cells` blanks by predicate |
| `to_ansi()` / `to_svg()` / `to_html()` | renderings a person can see; `Screen::parse(text)` reads the text format back |

Expand Down Expand Up @@ -532,9 +542,11 @@ directory (see §9b).
colour), `termlens diff old.snap new.snap.new` prints the cell diff of two
saved screens and exits 1 if anything changed, `termlens render --svg
failing.snap` makes an image. A saved screen is any text termlens prints
— an insta `.snap`, the grid a wait error leaves in a log.
— what `inspect` writes to stdout (`termlens inspect myapp > before.txt`;
its trailer goes to stderr), an insta `.snap`, the grid a wait error
leaves in a log — or the JSON the `serde` feature writes.
- In CI, set `TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens` on the
test step and add `uses: vyncint/termlens/.github/actions/report@v0.10.0`
test step and add `uses: vyncint/termlens/.github/actions/report@v0.11.0`
with `if: failure()` after it: every screen a failing wait embedded, and
every `.snap.new` with its diff, lands in the pull request's step summary.

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ listed under a **Changed** or **Removed** heading.

## [Unreleased]

### Changed

- **termlens 0.11** for the PTY suite, and the vendored skill with it. 0.11
is termlens's stability candidate: from it no promised item changes
incompatibly before its 1.0, so this requirement should hold for a while.

Its one breaking change lands here as a simplification.
`Screen::unsupported()` returns a view instead of a slice of `Arc<str>`,
and `unsupported_overflow()` folds into it — so the pinned list and "the
record is not truncated" are now **one** assertion in
`tests/emulation.rs`, because the view compares equal to a slice only
when the retained shapes match *and* nothing overflowed the bound. The
`Vec<String>` helper that existed to make the comparison possible is
gone.

## [0.8.1] - 2026-09-08

A chart that was not moving stopped saying so twelve times a second, and the
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ signal-hook = { version = "0.3", default-features = false, features = ["iterator
# The end-to-end suite: the real binary, in a real PTY, asserted on the rendered
# screen — and, with `decode`, on the pixels of the images that went out over
# kitty and sixel, which no rendered screen can show.
termlens = { version = "0.10", features = ["decode", "regex", "serde"] }
termlens = { version = "0.11", features = ["decode", "regex", "serde"] }

[profile.release]
lto = true
Expand Down
12 changes: 10 additions & 2 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,16 @@ fn inspect_drives_mossaic_itself() {
screen.contains("contributions in"),
"the real chart:\n{screen}"
);
// The trailer goes to **stderr** since termlens 0.11 (termlens#340), so
// what stdout carries is a saved screen — `inspect … > file` needs no
// editing before `render` or `diff` will read it.
assert!(
screen.contains("still running at the deadline"),
"mossaic is a TUI, so inspect reports the deadline rather than an exit:\n{screen}"
String::from_utf8_lossy(&out.stderr).contains("still running at the deadline"),
"mossaic is a TUI, so inspect reports the deadline rather than an exit: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!screen.contains("--- "),
"and stdout is the screen alone:\n{screen}"
);
}
24 changes: 10 additions & 14 deletions tests/emulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! mossaic's bytes. If mossaic emits a sequence the emulator does not
//! implement, that grid is quietly wrong and *every* screen assertion in this
//! repository is being made against a plausible-looking fiction. termlens
//! 0.10 made that checkable: `Screen::unsupported` lists what was dropped.
//! made that checkable: `Screen::unsupported` lists what was dropped.
//!
//! These are deliberately whole-suite invariants rather than feature tests.
//! They are cheap, and when one breaks the right response is to distrust the
Expand Down Expand Up @@ -45,10 +45,6 @@ fn chart(graphics: Option<Graphics>, cols: u16, rows: u16) -> termlens::Result<T
Ok(t)
}

fn unsupported(screen: &Screen) -> Vec<String> {
screen.unsupported().iter().map(|s| s.to_string()).collect()
}

/// The invariant, in all three rendering modes. The image paths are the ones
/// worth checking hardest: they put bytes on the wire that no cell shows, so
/// a dropped sequence there is invisible in every other assertion.
Expand All @@ -61,17 +57,17 @@ fn the_emulator_drops_nothing_that_could_change_a_cell() -> termlens::Result<()>
] {
let t = chart(graphics, 120, 30)?;
let screen = t.screen();
// One comparison for both halves of the record: termlens 0.11's
// `Unsupported` view is equal to a slice only when the retained
// shapes match *and* nothing overflowed the bound, so a truncated
// record fails here rather than passing as a shorter list.
assert_eq!(
unsupported(&screen),
screen.unsupported(),
EXPECTED_UNSUPPORTED,
"{label}: mossaic emitted a sequence termlens does not model. \
Until it is understood, every screen assertion in this suite is \
being made against a grid that may be wrong."
);
assert_eq!(
screen.unsupported_overflow(),
0,
"{label}: the record is complete, not truncated"
"{label}: mossaic emitted a sequence termlens does not model, or \
the record was truncated. Until it is understood, every screen \
assertion in this suite is being made against a grid that may \
be wrong."
);
if graphics.is_some() {
assert!(
Expand Down