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 @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>`,
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
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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 5 additions & 6 deletions crates/oxidelake-runtime/tests/oxide_tui_pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(),
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()?;
Expand Down
33 changes: 14 additions & 19 deletions crates/oxidelake-tui/tests/emulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Terminal> {
Expand All @@ -42,22 +41,18 @@ fn spawn(cols: u16, rows: u16) -> termlens::Result<Terminal> {
)
}

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

Expand Down
11 changes: 9 additions & 2 deletions crates/oxidelake-tui/tests/termlens_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}

Expand Down
Loading