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