diff --git a/.claude/skills/termlens/SKILL.md b/.claude/skills/termlens/SKILL.md index 49394fa..5eb16a6 100644 --- a/.claude/skills/termlens/SKILL.md +++ b/.claude/skills/termlens/SKILL.md @@ -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. @@ -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 @@ -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, @@ -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` ``` @@ -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()); @@ -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`: `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 | @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e272657..a42b087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, + 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` 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 diff --git a/Cargo.lock b/Cargo.lock index 3149485..b223492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,9 +1562,9 @@ dependencies = [ [[package]] name = "termlens" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c01f5cc1f410aa421987ca194b72620037422bddcfeb15b1f8da40e52b03b348" +checksum = "0d98cd6371f623f715816434504c61c187d77be7dffe0955bc3e28156dc93593" dependencies = [ "insta", "libc", diff --git a/Cargo.toml b/Cargo.toml index 1d804a2..f4ac92d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/tests/cli.rs b/tests/cli.rs index 06cd3bd..83e342d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -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}" ); } diff --git a/tests/emulation.rs b/tests/emulation.rs index 39edf2c..a4bee28 100644 --- a/tests/emulation.rs +++ b/tests/emulation.rs @@ -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 @@ -45,10 +45,6 @@ fn chart(graphics: Option, cols: u16, rows: u16) -> termlens::Result Vec { - 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. @@ -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!(