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 @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>`,
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<String>` 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
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/oxmera-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 16 additions & 21 deletions crates/oxmera-cli/tests/emulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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()
Expand Down Expand Up @@ -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()?;
Expand All @@ -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)| {
Expand Down
24 changes: 18 additions & 6 deletions crates/oxmera-cli/tests/termlens_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down