diff --git a/AGENTS.md b/AGENTS.md index 0fd0493..d74e004 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,8 @@ After any change: 1. `cargo build` must succeed with zero errors and zero warnings. 2. `cargo test` must pass. 3. `cargo run --release` must open a window and display a working zsh prompt. -4. Manually verify: type commands, run `vim`, run `htop`, check colors with `echo -e "\e[31mred\e[0m"`. +4. `./scripts/manual-smoke.sh` should prepare a smoke workspace and checklist; walk through the listed `tcat`/`tdiff`/`tjson`, tab, URL, clipboard, color, scrollback, and full-screen-app checks. +5. Manually verify at least: type commands, run `vim`, run `htop` (or `top`), check colors with `echo -e "\e[31mred\e[0m"`. Unit tests live inline in each source file. Run a specific module with e.g. `cargo test --lib terminal`. diff --git a/Cargo.lock b/Cargo.lock index 181ca10..4471e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1444,6 +1444,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -1474,6 +1483,31 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.39.2" @@ -1498,6 +1532,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -1592,6 +1664,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.14.1" @@ -1892,6 +1976,7 @@ dependencies = [ "objc2", "pollster", "portable-pty", + "proptest", "rustybuzz", "serde_json", "syntect", @@ -2048,6 +2133,12 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bidi-mirroring" version = "0.2.0" @@ -2129,6 +2220,15 @@ dependencies = [ "quote", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 0d091b5..c53caf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ syntect = { version = "5", default-features = false, features = ["default-syntax serde_json = "1" [dev-dependencies] +proptest = "1" tempfile = "3" [profile.release] diff --git a/README.md b/README.md index 696fecb..11d6202 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,18 @@ cargo run --release Builds four binaries: `term`, `tcat`, `tdiff`, `tjson`. No external tools or scripts needed. +## Testing + +```sh +cargo build --all-targets +cargo test +./scripts/manual-smoke.sh +``` + +Automated coverage includes inline unit tests, subprocess integration tests for `tcat` / `tdiff` / `tjson`, and property-style terminal tests that stress arbitrary byte streams plus mixed write/resize/scroll action sequences. + +`./scripts/manual-smoke.sh` creates a temporary fixture workspace with a sample Rust file, JSON fixture, URL fixture, and git diff, prints a macOS smoke checklist, then launches `term` in that workspace. Set `TERM_SMOKE_NO_LAUNCH=1` if you only want the workspace and checklist. + ## Features - **GPU-accelerated rendering** — wgpu/Metal pipeline with batched instancing and a 1024×1024 glyph atlas diff --git a/scripts/manual-smoke.sh b/scripts/manual-smoke.sh new file mode 100755 index 0000000..bfabadf --- /dev/null +++ b/scripts/manual-smoke.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_ROOT="${TMPDIR:-/tmp}" +WORKDIR="${TERM_SMOKE_DIR:-$(mktemp -d "${TMP_ROOT%/}/term-smoke.XXXXXX")}" + +mkdir -p "$WORKDIR" + +cat > "$WORKDIR/sample.rs" <<'EOF' +fn greet(name: &str) -> String { + format!("hello, {name}") +} + +fn main() { + println!("{}", greet("term")); +} +EOF + +cat > "$WORKDIR/sample.json" <<'EOF' +{"service":"term","ok":true,"count":3,"items":["tabs","colors","json"]} +EOF + +cat > "$WORKDIR/url.txt" <<'EOF' +Cmd-click this URL inside term: https://example.com/docs/term?smoke=1 +EOF + +cat > "$WORKDIR/emit-json.sh" <<'EOF' +#!/bin/sh +printf '%s\n' 'server booting' +printf '%s\n' '{"mode":"pty","service":"term","ok":true,"count":3}' +printf '%s\n' 'stderr line from child' >&2 +EOF +chmod +x "$WORKDIR/emit-json.sh" + +( + cd "$WORKDIR" + git init -q + git config user.name "term smoke" + git config user.email "term-smoke@example.com" + git add sample.rs sample.json url.txt emit-json.sh + git commit -qm "smoke fixtures" +) + +cat > "$WORKDIR/sample.rs" <<'EOF' +use std::io::{self, Write}; + +fn greet(name: &str) -> String { + format!("hello, {name}") +} + +fn main() { + let mut stdout = io::stdout(); + writeln!(stdout, "{}", greet("term smoke")).unwrap(); +} +EOF + +cat > "$WORKDIR/notes.txt" <<'EOF' +Use this file for copy/paste and selection checks. +EOF + +cat <= bytes.len() { + break; + } + let end = (cursor + len.max(1)).min(bytes.len()); + term.process(&bytes[cursor..end]); + cursor = end; + } + if cursor < bytes.len() { + term.process(&bytes[cursor..]); + } + } + fn assert_chunking_equivalence(cols: usize, rows: usize, bytes: &[u8]) { let mut whole = t(cols, rows); whole.process(bytes); @@ -1105,6 +1121,56 @@ mod tests { ); } + fn assert_terminal_invariants(term: &Terminal) { + let state = &term.state; + assert_eq!(state.grid.len(), state.rows); + assert_eq!(state.alt_grid.len(), state.rows); + assert!(state.rows > 0); + assert!(state.cols > 0); + assert!(state.cursor_row < state.rows, "cursor_row={} rows={}", state.cursor_row, state.rows); + assert!(state.cursor_col < state.cols, "cursor_col={} cols={}", state.cursor_col, state.cols); + assert!(state.scroll_top <= state.scroll_bottom); + assert!(state.scroll_bottom < state.rows); + assert!(state.viewport_offset <= state.scrollback.len()); + assert!(state.current_link_id as usize <= state.links.len()); + + for row in &state.grid { + assert_eq!(row.len(), state.cols); + for cell in row { + assert!(cell.link_id as usize <= state.links.len()); + } + } + for row in &state.alt_grid { + assert_eq!(row.len(), state.cols); + for cell in row { + assert!(cell.link_id as usize <= state.links.len()); + } + } + for row in &state.scrollback { + for cell in row { + assert!(cell.link_id as usize <= state.links.len()); + } + } + } + + #[derive(Clone, Debug)] + enum RandomAction { + Write(Vec), + Resize { cols: usize, rows: usize }, + ScrollViewport(i32), + SnapToBottom, + } + + fn random_action_strategy() -> impl Strategy { + prop_oneof![ + proptest::collection::vec(any::(), 0..64).prop_map(RandomAction::Write), + (1usize..80, 1usize..32) + .prop_map(|(cols, rows)| RandomAction::Resize { cols, rows }), + (-200i32..=200).prop_map(RandomAction::ScrollViewport), + Just(RandomAction::SnapToBottom), + ] + } + fn shell_session_transcript() -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(b"\x1b]7;file://localhost/Users/alice/src/term\x07"); @@ -1134,6 +1200,43 @@ mod tests { bytes } + proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(64))] + + #[test] + fn arbitrary_byte_stream_chunking_is_stream_equivalent( + bytes in proptest::collection::vec(any::(), 0..512), + chunk_lengths in proptest::collection::vec(1usize..32, 0..128), + ) { + let mut whole = t(48, 16); + whole.process(&bytes); + assert_terminal_invariants(&whole); + let expected = snapshot(&whole); + + let mut chunked = t(48, 16); + process_chunk_plan(&mut chunked, &bytes, &chunk_lengths); + assert_terminal_invariants(&chunked); + + prop_assert_eq!(snapshot(&chunked), expected); + } + + #[test] + fn random_terminal_action_sequences_preserve_invariants( + actions in proptest::collection::vec(random_action_strategy(), 0..128), + ) { + let mut term = t(48, 16); + for action in actions { + match action { + RandomAction::Write(bytes) => term.process(&bytes), + RandomAction::Resize { cols, rows } => term.resize(cols, rows), + RandomAction::ScrollViewport(delta) => term.state.scroll_viewport(delta), + RandomAction::SnapToBottom => term.state.snap_to_bottom(), + } + assert_terminal_invariants(&term); + } + } + } + // ── Basic character output ──────────────────────────────────────────────── #[test]