From da9293ec953b0d79050f3bf96e367f8fcf0fe608 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:19:59 +0700 Subject: [PATCH 1/2] chore(deps): termlens 0.11, the stability candidate From 0.11 no promised termlens item changes incompatibly before its 1.0, so this requirement should hold for a while. The one breaking change simplifies the invariant the goldens rest on: `Screen::unsupported()` returns a view and `unsupported_overflow()` folds into it, and the view compares equal to a slice only when the retained shapes match *and* nothing overflowed the bound. The pinned list and "the record is complete" become one assertion, and the Vec helper that existed to make the comparison typecheck is gone. The pin's known-defect caveat goes with it: termlens#320 named blink and strikethrough as unsupported although the attribute shadow implements them, and was fixed upstream in 0.10.2. The vendored skill and the three `cli-version:` pins move to 0.11.0, and the report action to the v0.11.0 tag's SHA, verified with `gh api`. check-skill-version.sh holds all of them equal to the dependency. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .claude/skills/termlens/SKILL.md | 40 +++++++++++++++-------- .github/workflows/ci.yml | 4 +-- .github/workflows/stress.yml | 8 ++--- CHANGELOG.md | 20 ++++++++++++ Cargo.lock | 4 +-- crates/launchbound-tui/Cargo.toml | 2 +- crates/launchbound-tui/tests/emulation.rs | 33 ++++++++----------- 7 files changed, 69 insertions(+), 42 deletions(-) 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index c896084..d588827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,11 +39,11 @@ jobs: # and uploads the SVG/HTML. It installs termlens-cli itself, pinned to # the version the lockfile names so the renderer and the library that # wrote the file are one release — the same rule tests/cli.rs enforces. - - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + - uses: vyncint/termlens/.github/actions/report@b8af0fa457dd023aa708a493b7f485256009f1a5 # v0.11.0 if: failure() with: name: termlens-report-ci-${{ matrix.os }} - cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh + cli-version: "0.11.0" # checked against the manifest by check-skill-version.sh # The public API against the last published release. # diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index 3ff7a4b..e0dd02b 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -72,11 +72,11 @@ jobs: - run: cargo test -p launchbound-tui --test cli -- --ignored env: TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens - - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + - uses: vyncint/termlens/.github/actions/report@b8af0fa457dd023aa708a493b7f485256009f1a5 # v0.11.0 if: failure() with: name: termlens-report-stress-${{ matrix.os }} - cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh + cli-version: "0.11.0" # checked against the manifest by check-skill-version.sh hunt: name: hunt (${{ matrix.os }}, ${{ matrix.threads }} threads) @@ -137,8 +137,8 @@ jobs: || { echo "::error::suite flaked at ${THREADS} thread(s), iteration ${i}/${per}"; exit 1; } echo "::endgroup::" done - - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + - uses: vyncint/termlens/.github/actions/report@b8af0fa457dd023aa708a493b7f485256009f1a5 # v0.11.0 if: failure() with: name: termlens-report-hunt-${{ matrix.os }}-${{ matrix.threads }} - cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh + cli-version: "0.11.0" # checked against the manifest by check-skill-version.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index bc010c8..2b20a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ change measured timings are marked `bench:`. ## [Unreleased] +### Changed + +- **termlens 0.11** for the TUI's PTY suite, the vendored skill, and the + `termlens-cli` pins the report action uses in `ci.yml` and `stress.yml` + (`check-skill-version.sh` holds all three equal to the dependency). 0.11 + is termlens's stability candidate: from it no promised item changes + incompatibly before its 1.0. + + 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 + `crates/launchbound-tui/tests/emulation.rs`, because the view compares + equal to a slice only when the retained shapes match *and* nothing + overflowed the bound. + + The pin's known-defect caveat is gone with it: termlens#320, which named + blink and strikethrough as unsupported although its attribute shadow + implements them, was fixed upstream in 0.10.2. + ## [2.2.1] - 2026-09-10 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 13a0a10..aa6a460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2473,9 +2473,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 = [ "libc", "portable-pty", diff --git a/crates/launchbound-tui/Cargo.toml b/crates/launchbound-tui/Cargo.toml index 373b35e..01e0169 100644 --- a/crates/launchbound-tui/Cargo.toml +++ b/crates/launchbound-tui/Cargo.toml @@ -26,5 +26,5 @@ ratatui = "0.30" # failing wait under `TERMLENS_ARTIFACT_DIR` leaves `.screen.json` rather than # `.screen.txt` — which is what the report action in CI renders. `insta` (the # crate's default) stays off: goldens here go through LAUNCHBOUND_BLESS. -termlens = { version = "0.10", default-features = false, features = ["serde"] } +termlens = { version = "0.11", default-features = false, features = ["serde"] } serde_json = { workspace = true } diff --git a/crates/launchbound-tui/tests/emulation.rs b/crates/launchbound-tui/tests/emulation.rs index a559c9b..20b37d0 100644 --- a/crates/launchbound-tui/tests/emulation.rs +++ b/crates/launchbound-tui/tests/emulation.rs @@ -35,13 +35,12 @@ const TIMEOUT: Duration = Duration::from_secs(10); /// it is a sequence that *might* change a cell, and would need reading /// before the goldens are trusted again. /// -/// None of these is a termlens#320 false positive (`^[[5m`/`^[[25m`/`^[[9m`/ -/// `^[[29m`, blink and strikethrough, reported unsupported although the -/// attribute shadow implements them). This application never blinks and -/// never strikes through — `app.rs` uses `Modifier::BOLD` and -/// `Modifier::BOLD | Modifier::REVERSED` and no other modifier — and none of -/// those four bytes appears in its stream, so the pin carries no -/// known-defect caveat. +/// The pin carries no known-defect caveat. It used to note that termlens +/// reported blink and strikethrough as unsupported although its attribute +/// shadow implements them (termlens#320); that was fixed in termlens 0.10.2, +/// so an entry here is a real gap whatever this application's modifiers are +/// — and they are only `Modifier::BOLD` and `Modifier::BOLD | +/// Modifier::REVERSED` in any case. const EXPECTED_UNSUPPORTED: [&str; 1] = ["^[[59m"]; fn fixture(name: &str) -> PathBuf { @@ -63,10 +62,6 @@ fn spawn(run_dir: &str, size: (u16, u16)) -> termlens::Result { Ok(t) } -fn unsupported(screen: &Screen) -> Vec { - screen.unsupported().iter().map(|s| s.to_string()).collect() -} - /// The views, as (key, a needle true only of that view). /// /// The panel's own top border, because it is the one marker that is unique @@ -82,17 +77,17 @@ const VIEWS: [(char, &str); 3] = [ ]; fn check(label: &str, screen: &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}: launchbound-tui emitted a sequence termlens does not \ - model. Until it is understood, every golden in this crate is being \ - held against a grid that may be wrong:\n{screen}" - ); - assert_eq!( - screen.unsupported_overflow(), - 0, - "{label}: the record is complete, not truncated" + model, or the record was truncated. Until it is understood, every \ + golden in this crate is being held against a grid that may be \ + wrong:\n{screen}" ); } From b681e63f4a24bfc63a2cedf2186bedf20c7cf2d7 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:34:13 +0700 Subject: [PATCH 2/2] test(cli): inspect's trailer is on stderr since termlens 0.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit termlens#340 moved it there so that `inspect prog > file` saves a screen `render` and `diff` read back. These assertions read stdout, so they were the ones that noticed — and they only run under `--ignored`, which is why a local `cargo test` and `just ci` were both green while CI was not. Each now asserts the split rather than working around it: the trailer on stderr, and stdout carrying no trailer line at all. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/launchbound-tui/tests/cli.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/launchbound-tui/tests/cli.rs b/crates/launchbound-tui/tests/cli.rs index 39d03f9..0a4b975 100644 --- a/crates/launchbound-tui/tests/cli.rs +++ b/crates/launchbound-tui/tests/cli.rs @@ -161,10 +161,17 @@ fn inspect_drives_the_real_tui() { screen.contains("1 overview · 2 ranking · 3 rejections · 4 progress"), "and a whole frame, not a half-painted one:\n{screen}" ); + // The trailer goes to **stderr** since termlens 0.11 (termlens#340), so + // what stdout carries is a saved screen the tool reads back unedited. assert!( - screen.contains("still running at the deadline"), + String::from_utf8_lossy(&out.stderr).contains("still running at the deadline"), "launchbound-tui is a TUI, so inspect reports the deadline rather \ - than an exit status:\n{screen}" + than an exit status: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !screen.contains("--- "), + "and stdout is the screen alone:\n{screen}" ); }