Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions .claude/skills/termlens/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`
```

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<Location>`: `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 |

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>`,
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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/launchbound-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
11 changes: 9 additions & 2 deletions crates/launchbound-tui/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}

Expand Down
33 changes: 14 additions & 19 deletions crates/launchbound-tui/tests/emulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -63,10 +62,6 @@ fn spawn(run_dir: &str, size: (u16, u16)) -> termlens::Result<Terminal> {
Ok(t)
}

fn unsupported(screen: &Screen) -> Vec<String> {
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
Expand All @@ -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}"
);
}

Expand Down
Loading