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
138 changes: 117 additions & 21 deletions .claude/skills/termlens/SKILL.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions .github/scripts/check-skill-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# The vendored termlens skill must name the version this repository depends on.
#
# `.claude/skills/termlens/SKILL.md` is a copy of the file termlens ships for
# coding agents, and AGENTS.md makes it normative: "PTY tests follow the
# termlens skill". It is refreshed by hand, and the failure mode is silent —
# the dependency gets bumped, the copy does not, and every agent writing a
# PTY test here is then handed guidance for a version that is no longer the
# one in the tree. The 0.9 copy documented no `unsupported()`, no `diff`, no
# `find_all`, which is exactly the surface the 0.10 tests are built on.
#
# Nothing can diff the copy against upstream: the published crate does not
# ship the skill, so there is no registry copy to compare with. What *is*
# checkable is that the two versions agree, which is the drift that happens.
#
# Compares major.minor only. A termlens patch release does not rewrite the
# skill, and demanding a re-copy for every one of them would make this noise.
#
# Usage: check-skill-version.sh [SKILL.md] [Cargo.toml]
#
# Portable to the macOS leg of `ci.yml`: bash 3.2, BSD sed, no GNU-only flags.
set -euo pipefail

skill="${1:-.claude/skills/termlens/SKILL.md}"
manifest="${2:-crates/launchbound-tui/Cargo.toml}"

[ -f "$skill" ] || { echo "::error::$skill does not exist"; exit 1; }
[ -f "$manifest" ] || { echo "::error::$manifest does not exist"; exit 1; }

# "Written against **termlens 0.10.1**." -> 0.10.1
skill_version="$(sed -n 's/.*Written against \*\*termlens \([0-9][0-9.]*\)\*\*.*/\1/p' "$skill" | head -1)"
[ -n "$skill_version" ] || {
echo "::error::$skill has no 'Written against **termlens X.Y.Z**' line to check"
exit 1
}
skill_minor="$(echo "$skill_version" | cut -d. -f1,2)"

# termlens = { version = "0.10", default-features = false, ... } -> 0.10
dep_version="$(sed -n 's/^termlens = .*version = "\([0-9][0-9.]*\)".*/\1/p' "$manifest" | head -1)"
[ -n "$dep_version" ] || {
echo "::error::no termlens dependency with a version found in $manifest"
exit 1
}
dep_minor="$(echo "$dep_version" | cut -d. -f1,2)"

if [ "$skill_minor" != "$dep_minor" ]; then
echo "::error::the vendored termlens skill is written against ${skill_version} but this repository depends on ${dep_version}."
echo "::error::Refresh it: cp ../termlens/skills/termlens/SKILL.md ${skill}"
exit 1
fi

echo "the vendored termlens skill (${skill_version}) matches the dependency (${dep_version})"

# The same drift, one layer out. The termlens `report` action takes the
# termlens-cli version as a literal in the workflow files, and a literal beside
# a dependency is a pin that goes stale silently: the suite would then be
# rendered by a tool from a different release than the library that produced
# the screens. Nothing else compares the two, so this does.
cli_pins="$(grep -rhoE 'cli-version: *"[0-9][0-9.]*"' .github/workflows/ 2>/dev/null | grep -oE '[0-9][0-9.]+' | sort -u)"
if [ -n "$cli_pins" ]; then
for pin in $cli_pins; do
pin_minor="$(echo "$pin" | cut -d. -f1,2)"
if [ "$pin_minor" != "$dep_minor" ]; then
echo "::error::a workflow pins termlens-cli ${pin} but this repository depends on termlens ${dep_version}."
echo "::error::Bump every 'cli-version:' under .github/workflows/ to match."
exit 1
fi
done
echo "the termlens-cli pins in .github/workflows ($(echo "$cli_pins" | tr '\n' ' ')) match the dependency (${dep_version})"
fi
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ jobs:
with:
tool: just,cargo-deny
- run: just ci
env:
# Every screen a failing termlens wait embeds is also written here,
# so the report step below can put the picture in the job summary
# instead of leaving it in a log someone has to scroll (termlens
# 0.10). The dependency carries the `serde` feature, so these are
# `.screen.json` rather than `.screen.txt`.
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
# A PTY failure is a picture, and a log is the worst place to read one.
# This renders every screen the suite left behind into the job summary
# 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
if: failure()
with:
name: termlens-report-ci-${{ matrix.os }}
cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh

# MSRV applies to the crates that do not need the pinned nightly
# (CONTRIBUTING.md); checked against the committed lockfile.
Expand Down
38 changes: 35 additions & 3 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
#
# `stress_100_runs_at_80x24` is skipped in the hunt. It is the same idea done
# in-process, and running it inside an outer loop would multiply a hundred
# spawns by twenty for no new information.
# spawns by twenty for no new information. `tests/cli.rs` is skipped by both
# jobs without asking: it is `#[ignore]`d, and the gate runs it by name.
name: stress

on:
Expand Down Expand Up @@ -56,7 +57,26 @@ jobs:
- uses: actions/checkout@v4
- name: Install pinned toolchain
run: rustup show
- run: cargo test -p launchbound-tui --test tui
# The PTY suites: the goldens, and the emulator invariants every one of
# them rests on. TERMLENS_ARTIFACT_DIR is where a failing wait leaves
# the screen it was looking at, for the report step at the end.
- run: cargo test -p launchbound-tui --test tui --test emulation
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
# The termlens-cli suite is `#[ignore]`d so a plain `cargo test` never
# `cargo install`s a binary behind a contributor's back — launchbound-tui
# is published, so `cargo test` is something other people run. CI is
# where it should run, so CI asks for it by name. The install is the
# version the lockfile names, so the tool and the library are one
# release.
- 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
if: failure()
with:
name: termlens-report-stress-${{ matrix.os }}
cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh

hunt:
name: hunt (${{ matrix.os }}, ${{ matrix.threads }} threads)
Expand Down Expand Up @@ -97,6 +117,13 @@ jobs:
ITERS: ${{ inputs.iterations || '100' }}
THREADS: ${{ matrix.threads }}
WEIGHT: ${{ matrix.weight }}
# The whole point of the hunt is the once-in-fifty failure, and
# until now a flaked iteration left only whatever fitted in the
# log. Every screen an embedded wait error carries is written here
# instead, and the report step uploads them — one directory across
# all the iterations of a shard, so a flake at iteration 37 is
# still there at the end.
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
run: |
per=$(( WEIGHT * ITERS / 100 ))
# A small `iterations` must still exercise every concurrency, or a
Expand All @@ -105,8 +132,13 @@ jobs:
echo "${THREADS} thread(s): ${per} iterations of ${ITERS}"
for i in $(seq "$per"); do
echo "::group::${THREADS} threads, iteration ${i}/${per}"
cargo test -p launchbound-tui --release --test tui \
cargo test -p launchbound-tui --release --test tui --test emulation \
-- --test-threads="$THREADS" --skip stress_100_runs \
|| { 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
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
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ MSRV 1.88 for everything that does not need it.
satisfy both waits of a resize; `(cols, rows)` for a size and
`(row, col)` for a cell. A readiness predicate has to hold at the width
under test — `ready` looks for the footer's `q quit`, which is cut at
sixty columns.
sixty columns. The copy is normative, so it must name the version we
actually depend on: `just skill` checks that, and `just ci` runs it.
Refresh it with `cp ../termlens/skills/termlens/SKILL.md
.claude/skills/termlens/SKILL.md`.
- **The goldens rest on `tests/emulation.rs`.** Every frame assertion in
launchbound-tui reads a grid a VT emulator produced, and a sequence the
emulator drops makes that grid quietly wrong — a golden blessed from it
would record the fiction. `Screen::unsupported()` is pinned to exactly
`["^[[59m"]`. If that test fails, distrust the goldens until you know why
before touching anything else.
- **The pins move together or not at all**, and `just pins` checks that the
recorded sites agree before anything asks upstream. 2.0.0 moved four of
six, which left `pins.yml` measuring drift from a version nothing
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ change measured timings are marked `bench:`.

## [Unreleased]

### Changed

- **The PTY test harness moves to termlens 0.10.1** (from 0.9). The upgrade
itself is small — `drag` takes four column-first arguments now instead of
two coordinate pairs — but it brings the accessor this suite was missing.

- **`tests/emulation.rs` pins what the emulator drops.** Every screen
assertion in this crate reads a grid a VT emulator produced from the
binary's bytes: five golden files, two border scans and a styled banner. A
sequence that emulator does not implement makes the grid quietly wrong and
every one of those assertions a claim about a plausible-looking fiction.
0.10 made it checkable, and the answer is one `SGR 59` — underline colour,
which changes no cell. Pinned exactly, so anything joining it has to be
read before the suite is trusted again.

- **`tests/cli.rs` drives `termlens-cli`** against this crate's own saved
screens: rendered, diffed with its 0/1/2 exit codes, and `inspect` pointed
at the real binary. The tool is resolved at the version the lockfile names,
so it and the library are one release.

- **The vendored agent skill (`.claude/skills/termlens/SKILL.md`) is
refreshed to 0.10.1**, and `check-skill-version.sh` now fails when that copy
and the dependency disagree on major.minor. It had drifted two releases
behind with nothing to notice.

- **CI writes `TERMLENS_ARTIFACT_DIR` and renders failures into the job
summary** via termlens's `report` action, so a red PTY test arrives as a
picture rather than a grid in a log.

## [2.1.0] - 2026-09-05

Thirteen findings, all reported against 2.0.0 with a measured reproduction.
Expand Down
8 changes: 6 additions & 2 deletions Cargo.lock

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

7 changes: 6 additions & 1 deletion crates/launchbound-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ crossterm = "0.29"
ratatui = "0.30"

[dev-dependencies]
termlens = { version = "0.9", default-features = false }
# `serde` for the Screen <-> JSON round trip in tests/emulation.rs, and so a
# 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"] }
serde_json = { workspace = true }
Loading
Loading