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/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}" ); } 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}" ); }