From b37c506ed8edb9241932f5c893dc9ecbba4338ad Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:25:59 +0700 Subject: [PATCH 1/2] chore(deps): termlens 0.11, the stability candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 both PTY suites rest on — the dashboard's and the shipped binary's. `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 in each, and 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 moves to the 0.11 copy 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 | 2 +- CHANGELOG.md | 19 +++++++++ Cargo.lock | 4 +- Cargo.toml | 2 +- .../oxidelake-runtime/tests/oxide_tui_pty.rs | 11 +++-- crates/oxidelake-tui/tests/emulation.rs | 33 +++++++-------- 8 files changed, 70 insertions(+), 45 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 ea5db60..fdd71cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,7 +173,7 @@ jobs: # This renders every screen the suite left behind — and every insta # `.snap.new` — into the job summary, with SVG and HTML uploaded. It # installs termlens-cli itself, so nothing else here has to. - - 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-linux-${{ matrix.lane.name }} @@ -219,7 +219,7 @@ jobs: run: make test-metal-device # macOS is the leg where the PTY suite times out first, and the one # nobody can reproduce locally on a Linux box. - - 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-metal diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index 1b3e974..6cd4c8a 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -49,7 +49,7 @@ jobs: # for the report step below (termlens 0.10). TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens run: make stress-tui - - 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3117d..ca76d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ versions (0.x) may contain breaking changes; they are always listed under a ### Changed +- **termlens 0.10.1 → 0.11**, with the vendored skill and the report + action's pin in `ci.yml` and `stress.yml`. 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 one assertion, in both PTY suites: the + dashboard's (`oxidelake-tui/tests/emulation.rs`) and the shipped + binary's (`oxidelake-runtime/tests/oxide_tui_pty.rs`). The view compares + equal to a slice only when the retained shapes match *and* nothing + overflowed the bound, so a truncated record can no longer pass as a + shorter list. + + 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. + - **The PTY test harness moved to termlens 0.10.1** (from 0.9). The three committed screen snapshots are unchanged: `assert_screen_snapshot!` records styles by default in 0.10, and the text snapshots opt out with diff --git a/Cargo.lock b/Cargo.lock index d9e3f95..9590d78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5774,9 +5774,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 d1cda55..7c8ca34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ insta = "1" # exact bytes `TERMLENS_ARTIFACT_DIR` writes when a wait fails in CI, so the # artefact a failure leaves behind is a format this suite has read back. It # also makes those artefacts `.screen.json` rather than `.screen.txt`. -termlens = { version = "0.10", features = ["serde"] } +termlens = { version = "0.11", features = ["serde"] } assert_cmd = "2" tempfile = "3" rand = "0.10" diff --git a/crates/oxidelake-runtime/tests/oxide_tui_pty.rs b/crates/oxidelake-runtime/tests/oxide_tui_pty.rs index 38d536a..0fa9180 100644 --- a/crates/oxidelake-runtime/tests/oxide_tui_pty.rs +++ b/crates/oxidelake-runtime/tests/oxide_tui_pty.rs @@ -84,15 +84,14 @@ fn oxide_tui_paints_the_dashboard_the_tui_crate_snapshots() -> termlens::Result< // the whole engine, and anything it printed would come through the same // stream. `^[[59m` is ratatui's underline-colour reset; it changes no // cell. + // The view compares equal to a slice only when the retained shapes match + // *and* nothing overflowed the bound, so this is the whole record. assert_eq!( - live.unsupported() - .iter() - .map(|u| u.to_string()) - .collect::>(), + live.unsupported(), ["^[[59m"], - "the shipped binary emitted a sequence termlens does not model:\n{live}" + "the shipped binary emitted a sequence termlens does not model, or \ + the record was truncated:\n{live}" ); - assert_eq!(live.unsupported_overflow(), 0); t.send(Key::Char('q'))?; let status = t.wait_exit()?; diff --git a/crates/oxidelake-tui/tests/emulation.rs b/crates/oxidelake-tui/tests/emulation.rs index 98f8e7b..17c7f67 100644 --- a/crates/oxidelake-tui/tests/emulation.rs +++ b/crates/oxidelake-tui/tests/emulation.rs @@ -26,12 +26,11 @@ use termlens::{Key, Screen, Terminal}; /// pinned exactly: anything joining it is a sequence that *might* change a /// cell and has to be read before the suite is trusted again. /// -/// Note what is *not* here. termlens#320 reports `^[[5m` / `^[[25m` / -/// `^[[9m` / `^[[29m` — blink and strikethrough — as unsupported although -/// the attribute shadow implements them, so those four are false positives -/// wherever they appear. This dashboard neither blinks nor strikes through -/// (`render.rs` uses BOLD, a foreground and a background and nothing else), -/// so they do not appear and this pin needs no exception. +/// The pin needs no exception. It used to carry one: termlens reported +/// blink and strikethrough as unsupported although its attribute shadow +/// implements them (termlens#320), so those four sequences were false +/// positives wherever they appeared. Fixed in termlens 0.10.2 — an entry +/// here is a real gap now, whatever a dashboard's modifiers are. const EXPECTED_UNSUPPORTED: [&str; 1] = ["^[[59m"]; fn spawn(cols: u16, rows: u16) -> termlens::Result { @@ -42,22 +41,18 @@ fn spawn(cols: u16, rows: u16) -> termlens::Result { ) } -fn unsupported(screen: &Screen) -> Vec { - screen.unsupported().iter().map(|s| s.to_string()).collect() -} - 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}: the dashboard emitted a sequence termlens does not model. \ - Until it is understood, every screen assertion in this crate is being \ - made against a grid that may be wrong.\n{screen}" - ); - assert_eq!( - screen.unsupported_overflow(), - 0, - "{label}: the record is complete, not truncated" + "{label}: the dashboard emitted a sequence termlens does not model, \ + or the record was truncated. Until it is understood, every screen \ + assertion in this crate is being made against a grid that may be \ + wrong.\n{screen}" ); } From c0930d394cbc11cca2ff6e64bf37690a171a8c80 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/oxidelake-tui/tests/termlens_cli.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/oxidelake-tui/tests/termlens_cli.rs b/crates/oxidelake-tui/tests/termlens_cli.rs index 18b5c4f..2fc6960 100644 --- a/crates/oxidelake-tui/tests/termlens_cli.rs +++ b/crates/oxidelake-tui/tests/termlens_cli.rs @@ -169,10 +169,17 @@ fn termlens_cli_inspect_drives_the_real_dashboard() { assert!(screen.contains(panel), "no {panel} panel:\n{screen}"); } assert!(screen.contains("[CUDA]"), "the plan tags its operators"); + // 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"), "the dashboard is a TUI, so inspect reports the deadline rather than \ - an exit — a binary that fell out of the event loop would say so here:\n{screen}" + an exit — a binary that fell out of the event loop would say so here: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !screen.contains("--- "), + "and stdout is the screen alone:\n{screen}" ); }