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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ jobs:
#
# Priced and deferred, deliberately (#226): the default set on the macOS
# leg — a second full suite per PR for a fault class the stress workflow
# is better placed to find — and a --no-default-features consumer in
# install.yml, which would need the registry step there to stop assuming
# `decode`.
# is better placed to find. The other half #226 deferred, a
# --no-default-features consumer in install.yml, landed with #238: that
# workflow's matrix now runs both shapes, and its registry step demands
# `decode` only on the leg that asks for it.
features:
name: features
runs-on: ubuntu-latest
Expand Down
64 changes: 55 additions & 9 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
# here. That is why this runs daily and not only at publish.
# * A feature that resolves in this workspace and nowhere else. `decode` is
# off by default and pulls a dependency of its own, which makes it exactly
# the shape of thing that works until someone outside tries it.
# the shape of thing that works until someone outside tries it. The
# mirror image is a consumer that declines the defaults: an optional
# dependency a workspace sibling happens to enable looks fine here and
# fails for them. Two of the three in-house consumers are that shape
# (launchbound and reconverge; only mossaic takes `decode`), so both
# shapes are verified (#238).
#
# The consumer is a real crate in an empty directory, and the test it runs is
# a real PTY — which is the whole product, so nothing else would do. Nothing
Expand Down Expand Up @@ -56,11 +61,13 @@ concurrency:

jobs:
consume:
name: consume (${{ matrix.os }})
name: consume (${{ matrix.os }}, ${{ matrix.features }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
# The two shapes real consumers use — see the header (#238).
features: [decode, no-default-features]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
Expand All @@ -69,6 +76,9 @@ jobs:
env:
TAG: ${{ github.event.release.tag_name }}
WANTED: ${{ inputs.version }}
# Only the leg that asks for `decode` demands the registry advertise
# it; the other leg proves the crate stands with no feature at all.
WANTED_FEATURES: ${{ matrix.features == 'decode' && 'decode insta' || '' }}
run: |
api="https://crates.io/api/v1/crates/termlens"
agent="termlens-install-check (github actions)"
Expand Down Expand Up @@ -107,7 +117,7 @@ jobs:
# actually ask for — a feature renamed at the last moment would be
# invisible to every test in this repository.
features=$(printf '%s' "$body" | jq -r '.version.features | keys | join(",")')
for wanted in decode insta; do
for wanted in $WANTED_FEATURES; do
case ",$features," in
*",$wanted,"*) ;;
*) echo "::error::the published $version has no '$wanted' feature (it has: $features)"; exit 1 ;;
Expand All @@ -122,18 +132,24 @@ jobs:
- name: Build a consumer against it
env:
VERSION: ${{ steps.crate.outputs.version }}
FEATURES: ${{ matrix.features }}
run: |
cd "$RUNNER_TEMP"
cargo new --lib consumer --edition 2021 --vcs none
cd consumer
# Exactly what the README tells a reader to type, plus the feature
# that carries the decoder.
cargo add termlens@"=$VERSION" --dev --features decode
# Exactly what the README tells a reader to type, plus the flag this
# leg verifies: the feature that carries the decoder, or none of the
# defaults at all.
case "$FEATURES" in
decode) cargo add termlens@"=$VERSION" --dev --features decode ;;
no-default-features) cargo add termlens@"=$VERSION" --dev --no-default-features ;;
*) echo "::error::unknown matrix leg $FEATURES"; exit 1 ;;
esac

mkdir -p tests
cat > tests/published.rs <<'RUST'
//! The published crate, from an empty project: a real PTY, a real
//! screen, and the decoder behind the `decode` feature.
//! The published crate, from an empty project: a real PTY and a
//! real screen, in every feature configuration.
use std::time::Duration;

use termlens::{Key, Terminal};
Expand Down Expand Up @@ -163,6 +179,24 @@ jobs:
assert!(t.wait_exit()?.success());
Ok(())
}
RUST

if [ "$FEATURES" = decode ]; then
cat > tests/decode.rs <<'RUST'
//! The decoder behind the `decode` feature, reached from outside.
use std::time::Duration;

use termlens::{Key, Terminal};

fn sh(script: &str) -> termlens::Result<Terminal> {
Terminal::builder()
.size(40, 10)
.env_clear()
.timeout(Duration::from_secs(30))
.arg("-c")
.arg(script)
.spawn("/bin/sh")
}

#[test]
fn the_decode_feature_reaches_the_pixels() -> termlens::Result<()> {
Expand Down Expand Up @@ -192,9 +226,21 @@ jobs:
Ok(())
}
RUST
fi

echo "--- the consumer's manifest ---"
cat Cargo.toml
echo "--- what it resolved to ---"
cargo tree --depth 1 --edges normal,dev
cargo test --test published -- --nocapture
if [ "$FEATURES" = no-default-features ]; then
# The point of this leg. `insta` is the whole of the default
# feature's tree; a consumer that declined the defaults must not
# carry it, and an optional dependency that only resolves because
# a workspace sibling enables it would show up here and nowhere
# in this repository.
if cargo tree --edges normal,dev | grep -qE '(^|[^[:alnum:]_-])insta v'; then
echo "::error::a --no-default-features consumer still resolves insta"
exit 1
fi
fi
cargo test --tests -- --nocapture
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,53 @@ listed under a **Changed** or **Removed** heading.
state means nothing. `wait_exit` is deliberately unaffected: the child's
exit status is still true. (#211)

- **`snapshot_after` and `wait_stable`: the whole-screen snapshot as one
call, and a settle that output changing nothing cannot hold up.**
`snapshot_after(pred)` waits for the predicate, then for the picture to
hold still for 100ms, and returns that screen — DESIGN §2's three rules
for race-free waits without having to remember them. `wait_stable(quiet)`
is the settle on its own, and differs from `wait_idle` in what resets the
clock: changes rather than bytes, so a bell, a cell rewritten with the
glyph already in it or an answered query — output `wait_idle` can never
see silence through — is invisible to it. Both have `_for` twins, refuse
to settle inside an open synchronized update, count stillness that
predates the call, and return the screen they settled on.
- **`termlens::bin!("myapp")` spawns one of your package's binaries under
the harness defaults.** Every integration test of a binary opened with the
same five lines — a fixed 80x24 grid, `env_clear()`, a five-second
deadline, `spawn(env!("CARGO_BIN_EXE_myapp"))` — so the chain has a name.
Builder calls follow the name and override any default:
`termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?`. A
misspelled binary is a compile error naming the variable, not a spawn
failure at run time.
- **`Screen::mouse_modes` reports every mouse tracking mode the application
enabled, and `DECRQM` answers each one on its own evidence.** The backend
collapses `?9`/`?1000`/`?1002`/`?1003` into the one protocol a terminal
reports in — right for the input path, and unchanged there — so it could
not say which members of the group an application asked for: crossterm's
`EnableMouseCapture` sends three at once and only the last survived, a
regression from any-motion to button-motion tracking (losing hover) was
invisible, and a `DECRQM` probe for any member but the last had to be
answered "not recognized". The sequence tracker now keeps the requested
set; `mouse_mode()` still reports the protocol. (#151)
- **The fresh-install check verifies a `--no-default-features` consumer as
well as a `decode` one.** `install.yml` is the only job that builds
termlens from outside this workspace, and it did so in one shape — the
one the fewest real consumers use: of the three in-house ones, two declare
`default-features = false`. Its matrix now runs both shapes on Ubuntu and
macOS, the registry check demands the `decode` feature only on the leg
that asks for it, and the no-defaults leg fails if the consumer's tree
still resolves `insta`. (#238)
- **The `inspect` example answers `--help`, and takes its deadline and
silence window from flags.** `inspect --help` used to look for a program
called `--help`, and both timings were hardcoded, so an application slower
than five seconds to paint its first screen could not be inspected at all.
`--timeout SECONDS` (default 5) and `--idle MILLIS` (default 300) now sit
beside `--size`; `--help`/`-h` print one usage text to stdout and exit 0,
a missing program prints the same text to stderr and exits 1, and
`--version` names the termlens version the example was built from. An
unknown option is refused rather than spawned. (#229, #236)

### Changed

- **The smallest terminal is 2x2, not 1x1.** One column panics the emulator on
Expand Down Expand Up @@ -58,6 +105,33 @@ listed under a **Changed** or **Removed** heading.
shifts remain G0/G1 only (`SO`/`SI`); `LS2`/`LS3` and `DECSC`/`DECRC` of
charset state are still unmodelled. (#235)

- **`DECSC`/`DECRC` save and restore the character-set state.** Save,
jump, draw the frame, restore is how a full-screen application draws a
border, and the restore lost the designation, so the border after it
rendered as `lqk` — the failure #204 fixed, arriving through a different
door. `ESC 7` now saves G0–G3 and the locking shift alongside the cursor
the backend already saved, `ESC 8` restores them, a restore with nothing
saved returns to ASCII as xterm does, and `RIS` clears the slot so a
restore cannot resurrect a designation from before the reset. (#232)
- **`DECSTR` (soft reset, `CSI ! p`) is modelled.** The polite reset a
well-behaved TUI sends on startup and teardown parsed cleanly and did
nothing, so text printed after it kept rendering in the character set the
application had told the terminal to forget, while `RIS` got this right.
It now returns the character sets and the `DECSC` slot to power-on, the
cursor shape to the terminal's default, and turns off cursor-key mode,
bracketed paste, every mouse tracking mode and encoding, and focus
reporting; the cursor becomes visible and the alternate screen is left
alone, as specified. Attributes, margins, origin and insert modes and the
keypad are not replayed — nothing on `Screen` observes them — and the
README says so. (#233)
- **The UK character set is translated: `ESC ( A` then `#` draws `£`.**
The designation was parsed and then rendered as ASCII, so an application
printing a price in the UK set showed `#42` on the grid, a test asserting
`£42` failed against a correct application, and a snapshot that blessed
`#42` kept passing. The set differs from ASCII in that one position, so
that is the one byte translated; the alternate-ROM sets and the other
national sets still read as ASCII, and the docs now say which sets are
translated. (#234)
- **`find` no longer matches the blank padding past the end of a row.** Its
single-row path searched the row padded out to the terminal width while
`contains` searched the trimmed text, so `find("Total: ")` was `Some` on
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ fn quits_from_the_main_screen() -> termlens::Result<()> {
When a wait times out, the error embeds the screen — your CI log shows
exactly what the app was displaying, not "assertion failed: false".

The builder chain above is what every test of a package's own binary
starts from, so it has a name: `termlens::bin!("myapp")` spawns
`CARGO_BIN_EXE_myapp` at 80x24 with a cleared environment and a
five-second deadline, and builder calls after the name override any of it —
`termlens::bin!("myapp", size(120, 40), env("NO_COLOR", "1"))?`.

## What it is (and is not)

- **Not** an expect-style stream matcher — [rexpect] and [expectrl] already
Expand Down Expand Up @@ -213,6 +219,11 @@ design. termlens's position:
mid-escape-sequence, and no synchronized update is open. Silence is
evidence a render finished — not proof. Use it for "the app settled",
not for precise sequencing.
- **`snapshot_after(pred)` is the whole-screen snapshot with the rules
built in**: it waits for the predicate, then for the picture to hold
still, and returns that screen. `wait_stable(quiet)` is the settle on its
own; unlike `wait_idle` it is reset by *changes*, not bytes, so a bell or
a repaint that alters no cell does not keep it waiting.
- **Hermetic environments.** `env_clear()` blocks inheritance,
`TERM=xterm-256color` is pinned by default, fixtures draw no clocks and
no animations. The CI suite runs a 100-iteration
Expand All @@ -232,12 +243,13 @@ design. termlens's position:
decision (`Terminal::resize` says why). The visible grid stays the
fully-featured surface.
- **Character sets: G0–G3 designation, SO/SI locking shifts, SS2/SS3
single shifts, and one set translated.** `ESC ( ) * + Ps` designations,
single shifts, and two sets translated.** `ESC ( ) * + Ps` designations,
the `SO`/`SI` locking shifts, and `ESC N`/`ESC O` (SS2/SS3, one character)
are modelled, and the DEC Special Graphics set (`0`) is translated; every
other designation — the UK set, the alternate ROMs — reads as ASCII.
Locking shifts remain G0/G1 only (`LS2`/`LS3` are not modelled).
`DECSC`/`DECRC` do not save or restore the charset state.
are modelled; the DEC Special Graphics set (`0`) and the UK set (`A`,
`£` at `#`) are translated, and every other designation — the alternate
ROMs, the other national sets — is acknowledged and reads as ASCII.
`DECSC`/`DECRC` save and restore this state with the cursor. Locking
shifts remain G0/G1 only (`LS2`/`LS3` are not modelled).
- `wait_frame` needs the application to bracket its repaints in DEC 2026
synchronized updates, and only the last 8 completed frames are retained;
everything else waits with `wait_until`, under the three rules in
Expand Down Expand Up @@ -272,7 +284,11 @@ design. termlens's position:
terminal would infer.** The cursor shape follows `DECSCUSR` and is cleared
by a hard reset (`RIS`); the window title is not, because in xterm the
title is a window property that `RIS` does not restore, and guessing either
way would be the same error. Nothing here models `DECSTR` (soft reset).
way would be the same error. `DECSTR` (soft reset) resets what a `Screen`
can observe — cursor keys, bracketed paste, mouse tracking, focus
reporting, the cursor's visibility and shape, the character sets — and
leaves the alternate screen alone; attributes, margins, origin and insert
modes and the keypad are not modelled.
- **Two SGR style attributes are not modeled.** Overline (`SGR 53`) and double
underline (`SGR 21`) do not reach [`Style`](https://docs.rs/termlens/latest/termlens/struct.Style.html),
so `with_styles()` cannot distinguish those attributes from a plain cell.
Expand Down
Loading