From b405a2410a6b70b3eacce1210dc5851eb9b33ce8 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:25:03 +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 this suite rests 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 dashboard's pinned list and "the record is complete" become one assertion; doctor's "the report is plain text" becomes one call, since the view is non-empty when shapes overflowed too. The Vec helper 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 two `cli-version:` pins move to 0.11.0, and the report action to the v0.11.0 tag's SHA, verified with `gh api`. 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 | 4 +-- CHANGELOG.md | 19 +++++++++++++ Cargo.lock | 4 +-- crates/oxmera-cli/Cargo.toml | 2 +- crates/oxmera-cli/tests/emulation.rs | 37 +++++++++++-------------- 7 files changed, 68 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 7beb6fe..6bf43ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,10 +50,10 @@ jobs: # summary, with SVG and HTML uploaded. It installs termlens-cli # itself, pinned to the version the lockfile names so the renderer and # the harness are one release. - - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + - uses: vyncint/termlens/.github/actions/report@b8af0fa457dd023aa708a493b7f485256009f1a5 # v0.11.0 if: failure() with: - cli-version: "0.10.1" + cli-version: "0.11.0" name: termlens-report-ci-${{ matrix.os }} # The second configuration. Until 0.4.0 exactly one existed — every job diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index d6c3fb4..7ef0005 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -46,8 +46,8 @@ jobs: # A PTY failure is a picture, and a log is the worst place to read # one. This renders every screen the two steps above left behind into # the job summary, with SVG and HTML uploaded. - - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + - uses: vyncint/termlens/.github/actions/report@b8af0fa457dd023aa708a493b7f485256009f1a5 # v0.11.0 if: failure() with: - cli-version: "0.10.1" + cli-version: "0.11.0" name: termlens-report-stress-${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 401b776..1407a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **termlens 0.10.1 → 0.11**, with the vendored skill and the report + action's `cli-version:` pins 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, 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 dashboard's pinned + list and "the record is not truncated" are one assertion in + `tests/emulation.rs`, and `doctor`'s "the report is plain text" is one + call rather than two, because the view is non-empty when shapes + overflowed as well as when any were retained. The `Vec` helper + that existed to make the comparison typecheck is gone. + + The pin's known-defect caveat goes with it: termlens#320, which named + blink and strikethrough as unsupported although the attribute shadow + implements them, was fixed upstream in 0.10.2. + - **termlens 0.9.0 → 0.10.1**, with the `serde` feature, and the terminal suite grown into the surface it opens. No call site broke: the crate never used `drag`, a `termlens::Style` literal, `assert_screen_snapshot!` or a diff --git a/Cargo.lock b/Cargo.lock index 68fe58d..c634b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,9 +1923,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/crates/oxmera-cli/Cargo.toml b/crates/oxmera-cli/Cargo.toml index 7af1ecc..7bfbf78 100644 --- a/crates/oxmera-cli/Cargo.toml +++ b/crates/oxmera-cli/Cargo.toml @@ -35,5 +35,5 @@ signal-hook = "0.3" # `.screen.json` under TERMLENS_ARTIFACT_DIR (see ci.yml / stress.yml), and # it is what `tests/emulation.rs` round-trips a dashboard frame through. # It costs no new crate here: serde_json is already in the lockfile. -termlens = { version = "0.10", features = ["serde"] } +termlens = { version = "0.11", features = ["serde"] } serde_json = "1" diff --git a/crates/oxmera-cli/tests/emulation.rs b/crates/oxmera-cli/tests/emulation.rs index eb98782..ce2d8fe 100644 --- a/crates/oxmera-cli/tests/emulation.rs +++ b/crates/oxmera-cli/tests/emulation.rs @@ -38,17 +38,14 @@ fn fixture(name: &str) -> PathBuf { /// *might* change a cell, and would need reading before the goldens are /// trusted again. /// -/// It is **not** the known termlens false positive (termlens#320), which -/// reports `^[[5m`/`^[[25m`/`^[[9m`/`^[[29m` — blink and strikethrough — -/// as unsupported although the attribute shadow does implement them. Those -/// cannot appear here: the dashboard sets neither attribute on any cell, as -/// `tests/styles.rs` pins by enumerating every distinct style it draws. +/// There is no longer a known false positive to rule out. termlens#320 — +/// `^[[5m`/`^[[25m`/`^[[9m`/`^[[29m`, blink and strikethrough, reported +/// unsupported although the attribute shadow does implement them — was +/// fixed in termlens 0.10.2, so an entry here is a real gap in every case. +/// (The dashboard sets neither attribute anyway, as `tests/styles.rs` pins +/// by enumerating every distinct style it draws.) const DASHBOARD_UNSUPPORTED: [&str; 1] = ["^[[59m"]; -fn unsupported(screen: &Screen) -> Vec { - screen.unsupported().iter().map(|s| s.to_string()).collect() -} - /// The dashboard, painted and settled, at a given geometry. fn dashboard(cols: u16, rows: u16) -> termlens::Result<(Terminal, Screen)> { let mut t = Terminal::builder() @@ -84,18 +81,17 @@ fn the_emulator_drops_nothing_that_could_change_a_cell() -> termlens::Result<()> // The dashboard: ratatui's style resets, and nothing else. for (cols, rows) in [(100, 45), (80, 30)] { let (mut t, screen) = dashboard(cols, rows)?; + // 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(), DASHBOARD_UNSUPPORTED, "dashboard at {cols}x{rows}: oxmera emitted a sequence termlens \ - does not model. Until it is understood, every screen assertion \ - in this crate — the goldens included — is being made against a \ - grid that may be wrong.\n{screen}" - ); - assert_eq!( - screen.unsupported_overflow(), - 0, - "dashboard at {cols}x{rows}: the record is complete, not truncated" + does not model, or the record was truncated. Until it is \ + understood, every screen assertion in this crate — the goldens \ + included — is being made against a grid that may be wrong.\n{screen}" ); t.send(termlens::Key::Char('q'))?; t.wait_exit()?; @@ -109,12 +105,11 @@ fn the_emulator_drops_nothing_that_could_change_a_cell() -> termlens::Result<()> for (cols, rows) in [(100, 45), (80, 45)] { let screen = report(name, cols, rows)?; assert!( - unsupported(&screen).is_empty(), + screen.unsupported().is_empty(), "doctor {name} at {cols}x{rows}: the report is supposed to be \ plain text, but termlens dropped {:?}", - unsupported(&screen) + screen.unsupported() ); - assert_eq!(screen.unsupported_overflow(), 0); let styled = (0..screen.rows()) .flat_map(|r| (0..screen.cols()).map(move |c| (r, c))) .filter(|(r, c)| { From cdb7dab588896db98f3d616805568062b5e261fe 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/oxmera-cli/tests/termlens_cli.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/oxmera-cli/tests/termlens_cli.rs b/crates/oxmera-cli/tests/termlens_cli.rs index d0b8609..67fc299 100644 --- a/crates/oxmera-cli/tests/termlens_cli.rs +++ b/crates/oxmera-cli/tests/termlens_cli.rs @@ -273,13 +273,20 @@ fn inspect_drives_oxmera_and_gets_the_committed_picture() { String::from_utf8_lossy(&out.stderr) ); let printed = String::from_utf8_lossy(&out.stdout); + // The trailer reports the program's own exit, on **stderr** since + // termlens 0.11 (termlens#340) — so stdout is the screen alone and + // needs no separating here. assert!( - printed.contains("--- exited: exit code 0 ---"), - "the trailer reports the program's own exit:\n{printed}" + String::from_utf8_lossy(&out.stderr).contains("--- exited: exit code 0 ---"), + "the trailer reports the program's own exit: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !printed.contains("--- "), + "stdout is a saved screen:\n{printed}" ); - // The trailer is not part of the screen, so the grid is the header plus - // exactly `rows` lines. Saved on its own it is a screen `diff` reads. + // The header plus exactly `rows` lines, which is what `diff` reads. let grid: String = printed .lines() .take(46) @@ -327,9 +334,14 @@ fn inspect_drives_oxmera_and_gets_the_committed_picture() { ); let printed = String::from_utf8_lossy(&out.stdout); assert!( - printed.contains("still running at the deadline"), + String::from_utf8_lossy(&out.stderr).contains("still running at the deadline"), "a TUI holds the terminal, so inspect reports the deadline rather \ - than an exit:\n{printed}" + than an exit, on stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !printed.contains("--- "), + "stdout is a saved screen:\n{printed}" ); let grid: String = printed .lines()