From 88ce5b97ac455c4d1e341fcc105c5ce46bf99fae Mon Sep 17 00:00:00 2001 From: LoricAndre <57358788+LoricAndre@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:32:29 +0200 Subject: [PATCH] test: replace tmux e2e harness with cross-platform Zellij harness (#1139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: replace tmux e2e harness with cross-platform Zellij harness Rewrite the end-to-end test harness to drive `sk` through Zellij instead of tmux, keeping the same capabilities and public surface (ZellijController, Keys, wait, sk, the sk_test! DSL and the line!/keys!/out! helpers) so the existing tests port over with only import/type renames. Zellij has no detached-server model like tmux, so the harness spawns a Zellij client attached to an in-process pseudo-terminal via portable-pty (openpty on Unix, ConPTY on Windows). Because Zellij 0.44+ and portable-pty are both cross-platform, the harness — and the tests that only rely on it — are now available on Windows too: the interactive tests (formerly unix.rs) are un-gated. execute.rs, popup.rs and listen.rs stay unix-only for reasons unrelated to the multiplexer (PermissionsExt, a mock sh/tmux binary, unix sockets). Key harness details: - Session per test via `zellij attach --create` on a fixed 80x24 PTY. - Keys injected as raw terminal bytes with `zellij action write`; screen read back with `zellij action dump-screen [--ansi]`, reversed to match the old bottom-anchored indexing. - A generated config disables startup tips, pane frames, mouse mode and — the crucial bit — the kitty keyboard protocol, so injected legacy escape sequences (arrows, etc.) reach sk. - All zellij CLI calls are run under a timeout and wait() has a wall-clock budget, so a wedged server surfaces as a fast retryable error instead of hanging a test. popup.rs unsets $ZELLIJ and sets $TMUX so skim selects its tmux popup backend (the mock) rather than the zellij one while running inside a Zellij pane. Because each test spins up a full Zellij session, the e2e binaries are put in a serialized nextest test-group; CI installs Zellij (all three OSes) in place of tmux, and the obsolete tmux setup-scripts are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * ci: fix rustfmt and stop Windows from cancelling the other nextest legs - Run `cargo +nightly fmt` on the new Zellij harness (rustfmt CI was red). - Set `fail-fast: false` on the nextest matrix so a failing OS leg no longer cancels the others, giving a clear pass/fail signal per platform. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * ci: install zellij via winget on Windows taiki-e/install-action has no prebuilt Zellij binary for Windows and falls back to `cargo install zellij`, which fails building openssl-sys from source on the runner. Install via winget on Windows instead (taiki-e still handles Linux/macOS), and expose winget's shim dir on PATH for the test step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test/ci: address review feedback on the Zellij harness - Pin the Windows winget Zellij install to 0.44.3 to match the Linux/macOS runners (reproducible CI). - Drop the unused `&locale` YAML anchor (actionlint flagged it). - `wait` now surfaces the last predicate error on timeout instead of a generic one, so a persistent failure keeps its diagnostic cause. - `output_with_timeout` tears down the child and reader threads on a `try_wait` error instead of leaking them. - Add rustdoc to the public harness surface (`sk`, `wait`, `Keys`, `ZellijController` and its methods). Deliberately not changed: a non-zero `zellij` exit is still not treated as an error (some `zellij action` calls exit non-zero in transient states — e.g. inline `sk` viewport teardown — while returning usable output; propagating it broke `inline_clear_on_exit`), and `to_lines` keeps trimming to preserve the tmux-parity bottom-anchored indexing the ported tests rely on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * ci: put the real zellij.exe dir on PATH for the Windows test step The winget install succeeds, but its Links shim wasn't reliably visible to the `cargo nextest` step's processes, so `which("zellij")` failed and every interactive test panicked at setup. Locate the installed zellij.exe under the WinGet Packages dir and add its directory to GITHUB_PATH instead, failing the step loudly if it isn't found. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * ci: reload PATH from registry after the MSI zellij install on Windows The winget Zellij package is an MSI installer that installs to Program Files and updates the machine PATH in the registry, not a portable under WinGet\Packages — so the previous "search Packages" lookup threw. Reload PATH from the machine/user registry values (with a Program Files fallback), then export zellij's directory via GITHUB_PATH for the test step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test/ci: gate interactive e2e tests off Windows Enabling the interactive tests on the Windows runner surfaced a real gap: the PATH/install issues are fixed (winget install works), but under the Windows runner's ConPTY the Zellij session never renders — dump-screen stays empty and wait_ready times out with "pane not rendered yet" for every interactive test. That's a harness-runtime gap on Windows (and sk's escape-code disambiguation on Windows would be a further blocker), so gate interactive.rs `#![cfg(not(windows))]` with a TODO, keeping the harness code cross-platform. Since no Windows test now uses the harness, drop the winget Zellij install from the Windows leg; Linux/macOS still install it via taiki-e. Adjust the docs that claimed the e2e tests run on Windows. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): gate Zellij harness tests to Linux only The Zellij-backed e2e harness renders reliably under the Linux CI runner, but on the macOS and Windows runners the pane never comes up under their PTY (`wait_ready` times out with "pane not rendered yet"). Restrict all four e2e test files (interactive, execute, popup, listen) to `#![cfg(target_os = "linux")]`, install Zellij only on the Linux runner, and update the harness/agent/architecture docs to match. The harness code stays cross-platform so macOS/Windows e2e can be re-enabled once their runners render the session. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): make the Zellij harness render on macOS and Windows The Zellij e2e harness previously only came up reliably on the Linux CI runner; on macOS and Windows the pane never rendered and every e2e test timed out with "pane not rendered yet". Root cause (surfaced by capturing the Zellij client's PTY output): Zellij's client/server startup handshake is racy — the client occasionally dies with "Received empty unknown from server" and the session never renders. It's rare on Linux (flaky) but frequent on the cold macOS/Windows runners. Harden the harness so it renders everywhere instead of gating tests to Linux: - Detect a dead session fast (drain thread flags client PTY EOF) and respawn a fresh session, up to SESSION_SPAWN_ATTEMPTS times, instead of waiting out the whole render budget and failing. - Resolve the pane's shell to an absolute `bash` path via `which`; the Zellij server's own environment may not have `bash` on PATH on the macOS/Windows runners, which would leave the pane with no shell to render. - Nudge the client's terminal size until the server gives the pane a non-zero geometry to render into (the initial size can be dropped under ConPTY / a cold runner). - Give the first render its own longer budget and, on timeout, surface a tail of the Zellij client output for diagnosing runners we can't reproduce locally. Un-gate the tests accordingly: interactive.rs (pure harness) now runs on Linux, macOS and Windows; execute.rs/popup.rs/listen.rs go back to #![cfg(unix)] (Linux + macOS) — their Windows-incompatibility is POSIX mock binaries / a unix socket, unrelated to the multiplexer. CI installs Zellij on all three OSes (taiki-e on Linux/macOS, winget on Windows) and the nextest job gets a 45-minute cap so a harness regression fails fast. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): fix macOS session-name rejection via short ZELLIJ_SOCKET_DIR The macOS runner failed every Zellij e2e test at CLI-parse time: error: Invalid value "skim_e2e_..." for '--session ': session name must be less than 0 characters This is not the render race the previous commit addressed. Zellij places each session's unix socket at `$ZELLIJ_SOCKET_DIR//`, and a unix socket path is length-capped by the OS (~104 bytes on macOS). Zellij's default base is `$TMPDIR/zellij-`; on the macOS runners `$TMPDIR` is a long `/var/folders/…` path that leaves ~0 bytes for the session name, so Zellij rejects every name and the client exits before it attaches (zellij-org/zellij#4211). Linux's short `/run`|`/tmp` base never hits this, which is why it only failed on macOS. - Export ZELLIJ_SOCKET_DIR=/tmp/skim-zj (a short base) on every zellij invocation — the attached client, `action`, and `run` — so they share a short socket path well under the cap on Linux and macOS alike. - Shorten session names (`sk_<=10 chars_<6 rand>`): several were derived from long test names (e.g. execute_interactive_child_keeps_receiving_ keys_fullscreen) and exceeded Zellij's ~36-char limit and ate socket budget; the random suffix still keeps them unique. Also fix a stale doc command in AGENTS.md (`cargo nextest --tests` -> `cargo nextest run --tests`), per PR review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): answer the DSR cursor-position probe so Windows renders The Windows nextest leg hung on every interactive.rs e2e test: Error: pane not rendered within 60s. zellij client output tail: \u{1b}[6n The captured client output was a single `ESC[6n` — a Device Status Report requesting the cursor position. Under the Windows ConPTY the Zellij client probes the terminal size by asking for the cursor position and blocks until the terminal replies; on Unix the size comes from the PTY ioctl, so the client never waits (which is why only Windows hung). The harness owns the master PTY — it *is* the terminal — so the drain thread now watches for `ESC[6n` and writes back a Cursor Position Report (`ESC[24;80R`, reporting the 24x80 pane). This unblocks the client so the pane renders. The reply is harmless on Linux/macOS (all 45 e2e tests still pass there), keeping interactive.rs on all three platforms. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): address review nits in the Zellij harness Follow-ups from PR review, none affecting the cross-platform fixes: - zellij_socket_dir() now returns io::Result and propagates a create_dir_all failure through run()/action()/spawn_once() instead of swallowing it, so a socket-dir problem surfaces directly rather than as a confusing downstream Zellij error. - Fix a latent typo in the (currently unused) assert_line!/line! macro: std::io::std::io::Error{,Kind} -> std::io::Error / std::io::ErrorKind, so the macro compiles if a test ever uses it. - tempfile() returns an InvalidData error instead of panicking on a non-UTF-8 temp path. Skipped the reviewer's suggestion to stop trimming captured output: the trim is load-bearing. It drops Zellij's blank padding rows so capture()[0] is the bottom content line that every test indexes against; stripping only CR/LF would reintroduce ~20 empty rows and shift every index. No test exercises intentionally-spaced items, so there is no real defect. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): silence unused_assignments warning in wait() `last_err` was initialised to `None` and always overwritten before it could be read, so the initial assignment was dead (unused_assignments warning at the top of every test build). Return the current predicate error directly on timeout instead of stashing it — same behaviour (the most recent error is surfaced), no dead variable, no warning. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): wrap assert_line! timeout error to 120 columns Pure formatting: split the Err/Error::new/format! construction in the (rustfmt-skipped) assert_line! macro body across lines to satisfy the repo's 120-column limit. No behaviour change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): guard against [-0] in the negative-index DSL macro @method_neg_dispatch used `lines.len() >= $idx`, which is always true for $idx == 0, so `@capture[-0]` would index `lines[lines.len()]` and panic. Require `$idx > 0` in both the predicate and diagnostic paths so a `[-0]` index falls through to the graceful "not enough lines" / "" handling instead. No current test uses negative indices; this only closes the latent edge case. Per PR review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): use forward slashes for Windows paths in the bash command With the DSR fix the Windows pane now renders and runs the command, which surfaced the next issue: the harness drives a `bash` shell but embedded native Windows paths (backslashes) into the command string. bash treats `\` as an escape, so `.\target\release\sk.exe` collapsed to `.targetreleasesk.exe` ("command not found") and the `C:\Users\...` redirect/mv targets would mangle the same way. Convert `\` to `/` for the `sk` binary and the outfile when building the bash command in sk(); bash on Windows accepts `./target/release/sk.exe` and `C:/Users/...`. On Unix the paths have no backslashes so it is a no-op, and the stored outfile the test reads back keeps native separators. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU * test(e2e): reap the Zellij client child in Drop ZellijController::drop killed the client with child.kill() but never waited on it, so on Unix each dropped controller left a zombie until the test binary exited — and many controllers are created per binary. Pair the kill with child.wait() (matching output_with_timeout) so the process is reaped immediately. Per PR review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU --------- Co-authored-by: Claude --- .config/nextest.toml | 30 +- .github/workflows/test.yml | 55 +- AGENTS.md | 31 +- ARCHITECTURE.md | 3 +- justfile | 3 +- tests/common/mod.rs | 5 +- tests/common/tmux.rs | 712 ----------------- tests/common/zellij.rs | 1201 +++++++++++++++++++++++++++++ tests/execute.rs | 10 +- tests/{unix.rs => interactive.rs} | 11 +- tests/listen.rs | 11 +- tests/popup.rs | 30 +- 12 files changed, 1333 insertions(+), 769 deletions(-) delete mode 100644 tests/common/tmux.rs create mode 100644 tests/common/zellij.rs rename tests/{unix.rs => interactive.rs} (73%) diff --git a/.config/nextest.toml b/.config/nextest.toml index a5cbbd12..4cd81d98 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,9 +1,8 @@ -experimental = ["setup-scripts", "wrapper-scripts"] +experimental = ["wrapper-scripts"] -[scripts.setup.stop-tmux] -command = "sh -c 'tmux kill-session -t skim_e2e || true'" -[scripts.setup.start-tmux] -command = "tmux new-session -d -s skim_e2e -n skim_e2e" +# The end-to-end tests drive `sk` inside a Zellij session that each test creates +# and tears down itself (see tests/common/zellij.rs), so — unlike the previous +# tmux-based harness — no shared multiplexer session needs to be set up here. # Valgrind wrapper for memory leak detection [scripts.wrapper.valgrind] @@ -16,17 +15,28 @@ command = [ "--suppressions=.config/valgrind.supp" ] +# The end-to-end tests each spin up a full Zellij session (server + client + +# PTY). Running many concurrently overwhelms the machine and makes the +# timing-sensitive tests flaky, so serialize everything in this group while the +# rest of the suite keeps running in parallel. (Under raw `cargo test`, pass +# `--test-threads=1` for the e2e test binaries instead.) +[test-groups] +e2e = { max-threads = 1 } + [profile.default] fail-fast = false retries = 2 -[[profile.default.scripts]] -platform = "cfg(unix)" -setup = ["stop-tmux", "start-tmux"] +[[profile.default.overrides]] +filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)' +test-group = 'e2e' [profile.default.junit] path = "junit.xml" [profile.ci] retries = 9 +[[profile.ci.overrides]] +filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)' +test-group = 'e2e' # Valgrind profile for memory leak detection # Usage: cargo nextest run --profile valgrind @@ -39,7 +49,6 @@ retries = 2 test-threads = 1 # Run tests serially to avoid interleaved valgrind output [[profile.valgrind.scripts]] platform = "cfg(unix)" -setup = ["stop-tmux", "start-tmux"] run-wrapper = "valgrind" # ThreadSanitizer profile for detecting data races and thread issues @@ -68,6 +77,3 @@ run-wrapper = "valgrind" fail-fast = false retries = 3 test-threads = 1 # TSan requires running tests serially -[[profile.tsan.scripts]] -platform = "cfg(unix)" -setup = ["stop-tmux", "start-tmux"] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c3ee024..dbb59b1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,14 @@ concurrency: jobs: nextest: runs-on: ${{matrix.runner}} + # Cap the wall-clock so a harness regression that makes the Zellij pane never + # render (each `wait_ready` then burning its full budget) fails the leg in + # minutes instead of letting a serialized e2e run drag on for hours. + timeout-minutes: 45 strategy: + # Report every OS independently: a failure on one (e.g. Windows) must not + # cancel the others, so we still get a clear signal from Linux and macOS. + fail-fast: false matrix: &matrix build: [linux, macos, windows] include: @@ -32,21 +39,39 @@ jobs: permissions: contents: read steps: - - &linux-deps - name: "[linux] Install dependencies" + - &zellij-install + # The e2e tests drive `sk` inside a Zellij session (Zellij 0.44+ runs on + # Linux, macOS and Windows). taiki-e/install-action fetches a prebuilt + # binary on Linux and macOS; it has no Windows binary and `cargo install + # zellij` fails building openssl from source on the runner, so Windows + # installs via winget below. + name: Install zellij (Linux/macOS) + if: runner.os != 'Windows' + uses: taiki-e/install-action@v2 + with: + tool: zellij@0.44.3 + - name: Install zellij (Windows) + if: runner.os == 'Windows' + shell: pwsh run: | - sudo apt-get install tmux - tmux -V - locale - if: runner.os == 'Linux' - - name: "[macos] Install dependencies" - run: | - brew install tmux - tmux -V - locale - if: runner.os == 'macOS' - env: - HOMEBREW_NO_AUTO_UPDATE: 1 + $ErrorActionPreference = 'Stop' + winget install --exact --id Zellij.Zellij --version 0.44.3 --source winget --accept-source-agreements --accept-package-agreements --disable-interactivity + # The winget package is an MSI that installs zellij and adds its dir to + # the machine PATH in the registry. Neither the current process nor + # GITHUB_PATH sees that until reloaded, so refresh PATH from the + # registry (with a Program Files fallback), then expose zellij's dir to + # later steps. + $env:PATH = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') + ';' + $env:PATH + $exe = (Get-Command zellij.exe -ErrorAction SilentlyContinue).Source + if (-not $exe) { $exe = (Get-ChildItem 'C:\Program Files' -Recurse -Filter zellij.exe -ErrorAction SilentlyContinue | Select-Object -First 1).FullName } + if (-not $exe) { throw "zellij.exe not found after winget install" } + $dir = Split-Path -Parent $exe + Write-Host "zellij installed at: $dir" + Add-Content -Path $env:GITHUB_PATH -Value $dir + & $exe --version + - name: Show locale + run: locale + if: runner.os != 'Windows' - &checkout name: Checkout repository @@ -93,7 +118,7 @@ jobs: permissions: contents: write steps: - - *linux-deps + - *zellij-install - *checkout - *toolchain - *nextest-install diff --git a/AGENTS.md b/AGENTS.md index 82f608fc..e74766ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - Run: `cargo run [--release]` - Test (all): `cargo nextest run` - Test (single): `cargo nextest test_name` -- Integration/E2E tests: `cargo nextest --tests` (will need tmux under the hood) +- Integration/E2E tests: `cargo nextest run --tests` (drives `sk` through Zellij under the hood; needs `zellij` >= 0.44 and `bash` on `$PATH`) - Memory leak detection: `cargo nextest run --profile valgrind` - Thread leak/race detection: 1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu` @@ -39,11 +39,30 @@ ## Testing -This application can be tested by : -- creating a new `tmux` session in the background (`tmux new-session -s -d`). Make sure to clear the `SKIM_DEFAULT_OPTIONS` env var. -- creating a new named tmux window in that session : `tmux new-window -d -P -F '#I' -n -t ` and configuring the pane naming using `tmux set-window-option -t pane-base-index 0` -- sending the command to run and input using `tmux send-keys -t ` -- when ready, capturing the window using `tmux capture-pane -b -t .0` and then saving the capture to a file using `tmux save-buffer -b ` +The end-to-end tests drive a real `sk` process through a terminal, using the +Zellij-backed harness in `tests/common/zellij.rs` (`ZellijController` + the +`sk_test!` DSL). It requires `zellij` (>= 0.44) and `bash` on `$PATH`. The +harness is cross-platform (Linux, macOS and Windows). The pure-harness tests in +`interactive.rs` run on all three platforms; `execute.rs`, `popup.rs` and +`listen.rs` stay `#![cfg(unix)]` for reasons unrelated to the multiplexer (they +install POSIX mock binaries / bind a unix socket), so they run on Linux and +macOS. A few harness details make the non-Linux runners work: the pane's shell +is resolved to an absolute `bash` path (the Zellij server's environment may lack +`bash` on `PATH`); `ZELLIJ_SOCKET_DIR` is forced to a short path so the session's +unix socket path stays under the OS cap (macOS's default `$TMPDIR` is too long); +the drain thread answers the client's cursor-position report (`ESC[6n`), which +the Windows ConPTY client blocks on to learn the terminal size; and `wait_ready` +nudges the client's terminal size until the server gives the pane a non-zero +geometry to render into. The harness drives Zellij with: +- `zellij attach --create ` (spawned on an in-process PTY via + `portable-pty`) to start a detached session; `SKIM_DEFAULT_OPTIONS` and friends + are cleared on the spawned process. +- `zellij --session action write ` to inject keystrokes. +- `zellij --session action dump-screen [--ansi]` to capture the pane. + +When exploring manually you can reproduce the same flow with those commands; the +config the harness writes disables startup tips, pane frames and the kitty +keyboard protocol (so injected legacy escape sequences reach `sk`). ## Insta Snapshot Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b6d290de..3e090e62 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,7 +153,8 @@ skim/ ← workspace root │ └── util.rs ← cursor helpers, style merging ├── tests/ ← integration & snapshot tests │ ├── common/ -│ │ └── insta.rs ← snap! / insta_test! macros for TUI snapshot testing +│ │ ├── insta.rs ← snap! / insta_test! macros for TUI snapshot testing +│ │ └── zellij.rs ← ZellijController + sk_test! DSL: cross-platform e2e harness driving sk in a Zellij pane │ ├── snapshots/ ← committed .snap files │ ├── ansi.rs ← ANSI rendering tests │ ├── options.rs ← option coverage tests diff --git a/justfile b/justfile index 30054f0c..59501e00 100644 --- a/justfile +++ b/justfile @@ -29,7 +29,8 @@ auto-release: test target="": cargo test --doc cargo nextest run {{ target }} - tmux kill-session -t skim_e2e + # Each e2e test creates and tears down its own Zellij session, so there is + # no shared multiplexer session to clean up here. bench-plot bins="./target/release/sk sk fzf": #!/usr/bin/env bash diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 28a02ca9..8a407777 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,8 +1,9 @@ #[macro_use] pub mod insta; +// Zellij-backed end-to-end harness. Cross-platform (Zellij 0.44+ and the +// in-process PTY both run on Windows), so it is not gated to unix. #[macro_use] -#[cfg(unix)] -pub mod tmux; +pub mod zellij; /// Raw binary path. Use `Command::new(SK)` to spawn directly; apply /// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed. diff --git a/tests/common/tmux.rs b/tests/common/tmux.rs deleted file mode 100644 index 8d3e2e75..00000000 --- a/tests/common/tmux.rs +++ /dev/null @@ -1,712 +0,0 @@ -use std::fmt::{Display, Formatter}; -use std::fs::File; -use std::io::{BufReader, ErrorKind, Read, Result}; -use std::path::Path; -use std::process::Command; -use std::thread::sleep; -use std::time::Duration; - -use rand::RngExt as _; -use rand::distr::Alphanumeric; -use tempfile::{NamedTempFile, TempDir, tempdir}; -use which::which; - -use crate::common::{SK, SKIM_SHELL_ENV_CLEAR}; - -pub fn sk(outfile: &str, opts: &[&str]) -> String { - format!( - "{}{} {} > {}.part; mv {}.part {}", - SKIM_SHELL_ENV_CLEAR, - SK, - opts.join(" "), - outfile, - outfile, - outfile - ) -} - -pub fn wait(pred: F) -> Result -where - F: Fn() -> Result, -{ - for _ in 1..500 { - if let Ok(t) = pred() { - return Ok(t); - } - sleep(Duration::from_millis(10)); - } - Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "wait timed out")) -} - -pub enum Keys<'a> { - Str(&'a str), - Key(char), - Ctrl(&'a Keys<'a>), - Alt(&'a Keys<'a>), - Enter, - Tab, - BTab, - Left, - Right, - BSpace, - Up, - Down, - Escape, -} - -impl Display for Keys<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { - use Keys::*; - match self { - Str(s) => write!(f, "{}", s), - Key(c) => write!(f, "{}", c), - Ctrl(k) => write!(f, "C-{}", k), - Alt(k) => write!(f, "M-{}", k), - Enter => write!(f, "Enter"), - Tab => write!(f, "Tab"), - BTab => write!(f, "BTab"), - Left => write!(f, "Left"), - Right => write!(f, "Right"), - BSpace => write!(f, "BSpace"), - Up => write!(f, "Up"), - Down => write!(f, "Down"), - Escape => write!(f, "Escape"), - } - } -} - -pub struct TmuxController { - pub window: String, - pub tempdir: TempDir, - pub outfile: Option, -} - -impl Default for TmuxController { - fn default() -> Self { - Self { - window: String::new(), - tempdir: tempfile::tempdir().expect("Failed to create tempdir"), - outfile: None, - } - } -} - -impl TmuxController { - pub fn run(args: &[&str]) -> Result> { - let output = Command::new(which("tmux").expect("Please install tmux to $PATH")) - .args(args) - .output()? - .stdout - .split(|c| *c == b'\n') - .map(|bytes| String::from_utf8(bytes.to_vec()).expect("Failed to parse bytes as UTF8 string")) - .collect::>(); - Ok(output[0..output.len() - 1].to_vec()) - } - - pub fn new_named(name: &str) -> Result { - let unset_cmd = "unset SKIM_DEFAULT_COMMAND SKIM_DEFAULT_OPTIONS PS1 PROMPT_COMMAND HISTFILE"; - - let full_name = format!( - "{name}-{}", - rand::rng() - .sample_iter(&Alphanumeric) - .take(4) - .map(char::from) - .collect::() - ); - let shell_cmd = "bash --rcfile None"; - - Self::run(&[ - "new-window", - "-d", - "-P", - "-F", - "#I", - "-t", - "skim_e2e:", - "-n", - &full_name, - &format!("{}; {}", unset_cmd, shell_cmd), - ])?; - - Self::run(&["set-window-option", "-t", &full_name, "pane-base-index", "0"])?; - - Ok(Self { - window: format!("skim_e2e:{full_name}"), - tempdir: tempdir()?, - outfile: None, - }) - } - - pub fn new() -> Result { - let name: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(16) - .map(char::from) - .collect(); - Self::new_named(&name) - } - - pub fn send_keys(&self, keys: &[Keys]) -> std::io::Result<()> { - print!("typing `"); - for key in keys { - Self::run(&["send-keys", "-t", &self.window, &key.to_string()])?; - print!("{}", key); - } - println!("`"); - Ok(()) - } - - pub fn tempfile(&self) -> Result { - Ok(NamedTempFile::new_in(&self.tempdir)? - .path() - .to_str() - .unwrap() - .to_string()) - } - - // Returns the lines in reverted order - pub fn capture(&self) -> Result> { - let tempfile = wait(|| { - let tempfile = self.tempfile()?; - Self::run(&[ - "capture-pane", - "-J", - "-b", - &self.window, - "-t", - &format!("{}.0", self.window), - ])?; - Self::run(&["save-buffer", "-b", &self.window, &tempfile])?; - Ok(tempfile) - })?; - - let mut string_lines = String::new(); - BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?; - - let str_lines = string_lines.trim(); - Ok(str_lines - .split("\n") - .map(|s| s.to_string()) - .collect::>() - .into_iter() - .rev() - .collect()) - } - - // Capture with ANSI escape sequences preserved (using -e flag) - // Returns the lines in reverted order with ANSI codes - pub fn capture_colored(&self) -> Result> { - let tempfile = wait(|| { - let tempfile = self.tempfile()?; - Self::run(&[ - "capture-pane", - "-e", - "-J", - "-b", - &self.window, - "-t", - &format!("{}.0", self.window), - ])?; - Self::run(&["save-buffer", "-b", &self.window, &tempfile])?; - Ok(tempfile) - })?; - - let mut string_lines = String::new(); - BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?; - - let str_lines = string_lines.trim(); - Ok(str_lines - .split("\n") - .map(|s| s.to_string()) - .collect::>() - .into_iter() - .rev() - .collect()) - } - - pub fn until(&self, pred: F) -> std::io::Result<()> - where - F: Fn(&[String]) -> bool, - { - match wait(|| { - let lines = self.capture()?; - if pred(&lines) { - return Ok(true); - } - Err(std::io::Error::other("pred not matched")) - }) { - Ok(true) => Ok(()), - Ok(false) => Err(std::io::Error::other(self.capture()?.join("\n"))), - _ => Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - self.capture()?.join("\n"), - )), - } - } - - /// Capture skim output without ANSI sequences - pub fn output(&self) -> Result> { - if let Some(ref outfile) = self.outfile { - self.output_from(outfile) - } else { - Err(std::io::Error::new( - ErrorKind::NotFound, - "You need to use start_sk to get an outfile", - )) - } - } - - /// Capture skim output from explicit outfile path - pub fn output_from(&self, outfile: &str) -> Result> { - wait(|| { - if Path::new(&outfile).exists() { - Ok(()) - } else { - Err(std::io::Error::new(ErrorKind::NotFound, "outfile does not exist yet")) - } - })?; - let mut string_lines = String::new(); - BufReader::new(File::open(outfile)?).read_to_string(&mut string_lines)?; - - let str_lines = string_lines.trim(); - Ok(str_lines - .split("\n") - .map(|s| s.to_string()) - .collect::>() - .into_iter() - .collect()) - } - - pub fn start_sk(&mut self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result { - let outfile = self.tempfile()?; - let sk_cmd = sk(&outfile, opts); - let cmd = match stdin_cmd { - Some(s) => format!("{} | {}", s, sk_cmd), - None => sk_cmd, - }; - println!("--- starting up sk ---"); - self.send_keys(&[Keys::Str(&cmd), Keys::Enter])?; - println!("--- sk is running ---"); - self.outfile = Some(outfile.clone()); - Ok(outfile) - } -} - -impl Drop for TmuxController { - fn drop(&mut self) { - let _ = Self::run(&["kill-window", "-t", &self.window]); - } -} - -// ============================================================================ -// sk_test! - Macro for writing compact tmux-based integration tests -// ============================================================================ -// -// USAGE GUIDE -// ----------- -// -// 1. INPUT SYNTAX: -// - Echo string: "a\\nb\\nc" -> Runs: echo -n -e 'a\nb\nc' -// - Command: @cmd "seq 1 100" -> Runs: seq 1 100 (pipe to sk) -// -// 2. DSL SYNTAX (Only syntax supported): -// -// sk_test!(test_name, "input", &["--opts"], { -// @capture[0] eq(">"); // Wait until capture[0] == ">" -// @capture[1] trim().starts_with("3/3"); // Wait until capture[1].trim().starts_with("3/3") -// @capture[-1] eq("foo"); // Wait until last line == "foo" -// @capture[*] contains("bar"); // Wait until any line contains "bar" -// @output[0] eq("result"); // Wait until output[0] == "result" -// @output[-1] eq("last"); // Wait until output[-1] (last line) == "last" -// @output[*] starts_with("prefix"); // Wait until any output line starts with "prefix" -// @capture_colored[0] contains("\x1b"); // Wait until colored capture contains ANSI -// @lines |l| (l.len() > 5); // Complex assertion with closure -// @keys Enter, Tab; // Send multiple keys -// @dbg; // Debug print current capture -// }); -// -// NOTE: All methods use wait() for consistent retry behavior. Any TmuxController -// method that takes no args and returns Result> can be used: -// capture, output, capture_colored, etc. -// -// EXAMPLES -// -------- -// -// Example 1: Simple test with echo input (all methods wait/retry) -// sk_test!(simple, "a\\nb\\nc", &[], { -// @capture[0] eq(">"); // Waits until condition is met -// @keys Enter; -// @output[0] eq("a"); // Waits until output is available -// }); -// -// Example 2: Using command input with @cmd -// sk_test!(with_seq, @cmd "seq 1 10", &["--bind", "'ctrl-t:toggle-all'"], { -// @capture[0] eq(">"); -// @keys Ctrl(&Key('t')); -// @capture[2] eq(">>1"); -// }); -// -// Example 3: Complex closures with @lines -// sk_test!(complex, "apple\\nbanana", &[], { -// @lines |l| (l.len() > 4); -// @keys Str("ana"); -// @lines |l| (l.iter().any(|x| x.contains("banana"))); -// }); -// -// Example 4: Method chaining -// sk_test!(chaining, " foo \\n bar ", &[], { -// @capture[2] trim().eq("foo"); -// @keys Enter; -// @output[0] trim().eq("foo"); -// }); -// -// Example 5: Using wildcards and negative indices -// sk_test!(wildcards, "apple\\nbanana\\ncherry", &[], { -// @capture[*] contains("3/3"); // Any line contains "3/3" -// @capture[-1] starts_with(">"); // Last line starts with ">" -// @keys Str("ana"); -// @capture[*] contains("banana"); // Any line contains "banana" -// @keys Enter; -// @output[0] eq("banana"); // First output line -// @output[-1] eq("banana"); // Last output line -// @output[*] starts_with("b"); // Any output line starts with "b" -// }); -// -// Example 6: New array syntax test -// sk_test!(new_syntax_test, "foo\\nbar\\nbaz", &[], { -// @capture[0] starts_with(">"); -// @capture[1] contains("3/3"); -// @keys Enter; -// @output[0] eq("foo"); -// @output[- 1] eq("foo"); -// }); -// -// Example 7: Wildcard syntax test -// sk_test!(wildcard_syntax_test, "apple\\nbanana\\ncherry", &[], { -// @capture[*] contains("3/3"); -// @keys Str("ana"); -// @capture[*] contains("banana"); -// @keys Enter; -// @output[*] eq("banana"); -// }); -// -// Example 8: Comprehensive example showing all features -// sk_test!(comprehensive_example, "foo\\nbar\\nbaz\\nqux", &[], { -// // Positive index with simple method -// @capture[0] starts_with(">"); -// -// // Positive index with method chain -// @capture[1] trim().contains("4/4"); -// -// // Wildcard - check if any line matches -// @capture[*] contains("foo"); -// -// // Send keys -// @keys Str("ba"); -// -// // Negative index - last line -// @capture[- 1] contains("bar"); -// -// // Select first match -// @keys Enter; -// -// // Output assertions -// @output[0] eq("bar"); // First output line -// @output[- 1] eq("bar"); // Last output line -// @output[*] starts_with("b"); // Any output line starts with "b" -// }); -// -// Example 9: Using capture_colored for ANSI escape sequences -// sk_test!(ansi_test, @cmd "echo -e '\\x1b[31mred\\x1b[0m'", &["--ansi"], { -// @capture[*] contains("red"); -// @capture_colored[*] contains("\x1b[31m"); // Check for ANSI codes -// @keys Enter; -// }); -// -// DSL COMMAND REFERENCE -// --------------------- -// @METHOD[N] method_chain Wait until METHOD[N].method_chain is true (N = line number) -// @METHOD[-N] method_chain Wait until METHOD[-N].method_chain is true (negative index) -// @METHOD[*] method_chain Wait until any line matches (uses .iter().any()) -// where METHOD is any TmuxController method returning Result>: -// - capture: Wait until condition is true -// - output: Wait until condition is true -// - capture_colored: Wait until condition is true on colored capture -// All methods use wait() for consistent retry behavior -// @lines |l| (expr) Call tmux.until(|l| expr)? with closure -// @keys key1, key2 Send keys (automatically adds ?) -// @dbg Debug print current capture -// -// NOTES -// ----- -// - The `tmux` variable is implicitly available in DSL blocks -// - All variants automatically handle Result propagation and Ok(()) return -// - DSL closures must be wrapped in parentheses: |l| (expr) -// - Method chains support any String/&str method: eq(), starts_with(), contains(), trim(), etc. -// - You can chain methods: trim().starts_with("foo") -// - Negative indices work like Python: -1 is last element, -2 is second-to-last, etc. -// - ALL methods use wait() with retry logic - no immediate assertions -// - wait() retries every 10ms for up to 10 seconds before timing out -// -#[allow(unused_macros)] -macro_rules! sk_test { - // Standard variant with echo input: explicit variable name with block - ($name:tt, $input:expr, $options:expr, $tmux:ident => $content:block) => { - #[test] - #[allow(unused_variables)] - fn $name() -> std::io::Result<()> { - let mut $tmux = crate::common::tmux::TmuxController::new()?; - $tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; - - $content - - Ok(()) - } - }; - - // Standard variant with arbitrary command: use @cmd marker - ($name:tt, @cmd $cmd:expr, $options:expr, $tmux:ident => $content:block) => { - #[test] - #[allow(unused_variables)] - fn $name() -> std::io::Result<()> { - let mut $tmux = crate::common::tmux::TmuxController::new()?; - $tmux.start_sk(Some($cmd), $options)?; - - $content - - Ok(()) - } - }; - - // DSL variant with echo input - ($name:tt, $input:expr, $options:expr, { $($content:tt)* }) => { - #[test] - #[allow(unused_variables)] - fn $name() -> std::io::Result<()> { - let mut tmux = crate::common::tmux::TmuxController::new_named(stringify!($name))?; - tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; - - sk_test!(@expand tmux; $($content)*); - - Ok(()) - } - }; - - // DSL variant with arbitrary command: use @cmd marker - ($name:tt, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => { - #[test] - #[allow(unused_variables)] - fn $name() -> std::io::Result<()> { - let mut tmux = crate::common::tmux::TmuxController::new_named(stringify!($name))?; - tmux.start_sk(Some($cmd), $options)?; - - sk_test!(@expand tmux; $($content)*); - - Ok(()) - } - }; - - // Token processing rules - (@expand $tmux:ident; ) => {}; - - // Generic method patterns - works with any TmuxController method - // @method[*] - check if any line matches (uses .iter().any()) - (@expand $tmux:ident; @ $method:ident [ * ] $($rest:tt)*) => { - sk_test!(@method_any_collect $tmux, $method, [] ; $($rest)*); - }; - - // @method[-idx] for negative index - supports arbitrary method chains (must come before positive) - (@expand $tmux:ident; @ $method:ident [ - $idx:literal ] $($rest:tt)*) => { - sk_test!(@method_neg_collect $tmux, $method, $idx, [] ; $($rest)*); - }; - - // @method[idx] for positive index - supports arbitrary method chains - (@expand $tmux:ident; @ $method:ident [ $idx:literal ] $($rest:tt)*) => { - sk_test!(@method_pos_collect $tmux, $method, $idx, [] ; $($rest)*); - }; - - // Collect tokens until semicolon for positive index - dispatches to wait or assert - (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { - sk_test!(@method_pos_dispatch $tmux, $method, $idx, [$($methods)*]); - sk_test!(@expand $tmux; $($rest)*); - }; - (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { - sk_test!(@method_pos_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); - }; - - // Dispatch for positive index - all methods use wait() - (@method_pos_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { - { - if crate::common::tmux::wait(|| { - let lines = $tmux.$method()?; - if lines.len() > $idx && lines[$idx].$($methods)* { - Ok(true) - } else { - Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) - } - }).is_err() { - let lines = $tmux.$method().unwrap_or_default(); - let actual = if lines.len() > $idx { &lines[$idx] } else { "" }; - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("Timed out waiting for {}[{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) - )); - } - } - }; - - // Collect tokens until semicolon for negative index - dispatches to wait or assert - (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { - sk_test!(@method_neg_dispatch $tmux, $method, $idx, [$($methods)*]); - sk_test!(@expand $tmux; $($rest)*); - }; - (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { - sk_test!(@method_neg_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); - }; - - // Dispatch for negative index - all methods use wait() - (@method_neg_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { - { - if crate::common::tmux::wait(|| { - let lines = $tmux.$method()?; - if lines.len() >= $idx { - let actual_idx = lines.len() - $idx; - if lines[actual_idx].$($methods)* { - Ok(true) - } else { - Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) - } - } else { - Err(std::io::Error::new(std::io::ErrorKind::Other, "not enough lines")) - } - }).is_err() { - let lines = $tmux.$method().unwrap_or_default(); - let actual_idx = lines.len().saturating_sub($idx); - let actual = if lines.len() >= $idx { &lines[actual_idx] } else { "" }; - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("Timed out waiting for {}[-{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) - )); - } - } - }; - - // Collect tokens until semicolon for wildcard [*] - dispatches to wait or assert - (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; ; $($rest:tt)*) => { - sk_test!(@method_any_dispatch $tmux, $method, [$($methods)*]); - sk_test!(@expand $tmux; $($rest)*); - }; - (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { - sk_test!(@method_any_collect $tmux, $method, [$($methods)* $next] ; $($rest)*); - }; - - // Dispatch for wildcard - all methods use wait() - (@method_any_dispatch $tmux:ident, $method:ident, [$($methods:tt)*]) => { - { - if crate::common::tmux::wait(|| { - let lines = $tmux.$method()?; - if lines.iter().any(|line| line.$($methods)*) { - Ok(true) - } else { - Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) - } - }).is_err() { - let lines = $tmux.$method().unwrap_or_default(); - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("Timed out waiting for {}[*] any line matching .{}, got: {:?}", stringify!($method), stringify!($($methods)*), lines) - )); - } - } - }; - - // @lines command for tmux.until with closure - (@expand $tmux:ident; @ lines | $param:ident | ( $($body:tt)* ) ; $($rest:tt)*) => { - $tmux.until(|$param| $($body)*)?; - sk_test!(@expand $tmux; $($rest)*); - }; - - // @keys command for send_keys - supports any number of keys - (@expand $tmux:ident; @ keys $($key:expr),+ ; $($rest:tt)*) => { - send_keys!($tmux, $($key),+)?; - sk_test!(@expand $tmux; $($rest)*); - }; - - // @dbg command for debug printing - (@expand $tmux:ident; @ dbg ; $($rest:tt)*) => { - match $tmux.capture() { - Ok(lines) => println!("DBG: capture: {:?}", lines), - Err(e) => println!("DBG: capture failed: {}", e), - } - match $tmux.output() { - Ok(lines) => println!("DBG: output: {:?}", lines), - Err(e) => println!("DBG: output failed: {}", e), - } - sk_test!(@expand $tmux; $($rest)*); - }; - - // Pass through regular Rust statements that access tmux (catch-all, must be last) - (@expand $tmux:ident; $stmt:stmt ; $($rest:tt)*) => { - #[allow(redundant_semicolons)] - { - $stmt; - sk_test!(@expand $tmux; $($rest)*); - } - }; - -} - -#[allow(unused_macros)] -macro_rules! assert_line { - ($tmux:ident, $line_nr:literal $($expression:tt)+) => { - { - if $tmux.until(|l| l.len() > $line_nr && l[$line_nr] $($expression)+).is_err() { - let lines = $tmux.capture().unwrap_or_default(); - let actual = if lines.len() > $line_nr { &lines[$line_nr] } else { "" }; - Err(std::io::std::io::Error::new(std::io::std::io::ErrorKind::TimedOut, format!("Timed out waiting for condition on line {}, got {} but expected it to {}", $line_nr, actual, stringify!($($expression)+)))) - } else { - Ok(()) - } - }? - }; -} - -#[allow(unused_macros)] -macro_rules! send_keys { - ($tmux:ident, $($key:expr),+) => { - $tmux.send_keys(&[$($key),+]) - }; -} - -#[allow(unused_macros)] -macro_rules! assert_output_line { - ($tmux:ident, $line_nr:literal $($expression:tt)+) => { - let output = $tmux.output()?; - println!("Output: {output:?}"); - assert!(output[$line_nr] $($expression)+, "Timed out waiting for condition on output line {}, expected it to {}", $line_nr, stringify!($($expression)+)); - }; -} - -// Ultra-short aliases for compact test writing -// Usage: line!(t, 0 == ">") instead of assert_line!(t, 0 == ">") -#[allow(unused_macros)] -macro_rules! line { - ($tmux:ident, $line_nr:literal $($expression:tt)+) => { - assert_line!($tmux, $line_nr $($expression)+) - }; -} - -#[allow(unused_macros)] -macro_rules! keys { - ($tmux:ident, $($key:expr),+) => { - send_keys!($tmux, $($key),+) - }; -} - -#[allow(unused_macros)] -macro_rules! out { - ($tmux:ident, $line_nr:literal $($expression:tt)+) => { - assert_output_line!($tmux, $line_nr $($expression)+) - }; -} diff --git a/tests/common/zellij.rs b/tests/common/zellij.rs new file mode 100644 index 00000000..efbf6c1f --- /dev/null +++ b/tests/common/zellij.rs @@ -0,0 +1,1201 @@ +//! Zellij-backed end-to-end test harness. +//! +//! This is a drop-in replacement for the old tmux-based harness. It drives a +//! real `sk` process running inside a [Zellij](https://zellij.dev) pane and +//! observes it exactly like a user would: by sending keystrokes and reading the +//! rendered screen back. +//! +//! Unlike tmux, Zellij has no detached-server model, so the harness spawns a +//! Zellij client attached to an in-process pseudo-terminal (via `portable-pty`, +//! which uses `openpty` on Unix and ConPTY on Windows). The harness is +//! cross-platform (Linux, macOS and Windows). A few details are load-bearing for +//! the non-Linux runners: +//! - the pane's shell is resolved to an absolute `bash` path (the Zellij +//! server's own environment may not have `bash` on `PATH`); +//! - `ZELLIJ_SOCKET_DIR` is forced to a short path so the session's unix socket +//! path stays under the OS cap (macOS's default `$TMPDIR` is too long — see +//! [`zellij_socket_dir`]); +//! - the drain thread answers the client's cursor-position report (`ESC[6n`), +//! which the Windows ConPTY client blocks on to learn the terminal size; +//! - [`ZellijController::wait_ready`] nudges the client's terminal size until the +//! server hands the pane a non-zero geometry to render into. +//! +//! The pure-harness e2e tests (`interactive.rs`) therefore run on all three +//! platforms; the tests that additionally install POSIX mock binaries +//! (`execute.rs`, `popup.rs`, `listen.rs`) stay `#![cfg(unix)]` for that reason, +//! not the multiplexer's. +//! +//! The public surface (`ZellijController`, `Keys`, `wait`, `sk`, the `sk_test!` +//! DSL and the `line!`/`keys!`/`out!` helpers) mirrors the previous tmux +//! harness so existing tests port over unchanged. + +use std::fmt::{Display, Formatter}; +use std::fs::File; +use std::io::{BufReader, ErrorKind, Read, Result, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use rand::RngExt as _; +use rand::distr::Alphanumeric; +use tempfile::{NamedTempFile, TempDir, tempdir}; +use which::which; + +use crate::common::{SK, SKIM_ENV_REMOVES, SKIM_SHELL_ENV_CLEAR}; + +/// Build the minimal Zellij config used for every test session. +/// +/// - startup tips / release notes off: they spawn a floating plugin pane that +/// steals focus, so keys and screen dumps would target the wrong pane. +/// - pane frames / mouse off: keep the captured pane free of decorations. +/// - kitty keyboard protocol off: with it on, `sk` negotiates the CSI-u key +/// encoding and ignores the legacy escape sequences the harness injects, so +/// arrow keys (Up/Down/…) would silently do nothing. +/// - `bash` as the default shell: the tests drive it with POSIX shell syntax +/// (`echo -n -e`, `printf`, pipelines). Bash ships with the macOS and Windows +/// CI runners as well, keeping the harness cross-platform. We resolve it to an +/// absolute path via `which` and hand that to Zellij: the Zellij *server* +/// spawns the pane's shell from its own environment, which on the Windows and +/// macOS runners does not necessarily have `bash` on `PATH`, so a bare +/// `default_shell "bash"` would leave the pane with no shell and nothing to +/// render. Backslashes are forward-slashed so the path survives KDL's string +/// escaping on Windows. +fn zellij_config() -> String { + let shell = which("bash") + .ok() + .and_then(|p| p.to_str().map(|s| s.replace('\\', "/"))) + .unwrap_or_else(|| "bash".to_string()); + format!( + r#"show_startup_tips false +show_release_notes false +pane_frames false +mouse_mode false +session_serialization false +support_kitty_keyboard_protocol false +default_shell "{shell}" +"# + ) +} + +/// Directory Zellij should place its per-session unix sockets in, exported as +/// `ZELLIJ_SOCKET_DIR` on every `zellij` invocation. +/// +/// A unix-domain socket path is length-capped by the OS (~104 bytes on macOS, +/// 108 on Linux), and Zellij's socket path is `// +/// `. Zellij's default base is `$TMPDIR/zellij-`, and on the +/// macOS runners `$TMPDIR` is a long `/var/folders/…` path that leaves ~0 bytes +/// for the session name — Zellij then rejects *every* session with "session name +/// must be less than 0 characters" (zellij-org/zellij#4211) and the client exits +/// before it attaches. Forcing a short base (`/tmp`) keeps the whole path well +/// under the cap on Linux and macOS alike. +fn zellij_socket_dir() -> Result { + #[cfg(unix)] + let dir = std::path::PathBuf::from("/tmp/skim-zj"); + #[cfg(not(unix))] + let dir = std::env::temp_dir().join("skim-zj"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Shell prompt installed once the pane comes up. It is deterministic (so the +/// harness can detect readiness) and always contains a literal `$` regardless +/// of the user, matching the assumptions the ported tests make about a returned +/// shell prompt. +const PROMPT: &str = "skim$ "; + +/// Build the shell command that runs `sk` with `opts`, clears the `SKIM_*` +/// environment inline, and atomically writes its selection to `outfile` (via a +/// `.part` rename) so readers never observe a half-written result file. +/// +/// The pane shell is `bash`, which treats `\` as an escape, so any native +/// Windows path embedded here (the `sk` binary, the outfile) must use forward +/// slashes — bash on Windows accepts `./target/release/sk.exe` and `C:/Users/…`. +/// On Unix these paths have no backslashes, so the conversion is a no-op. The +/// stored `outfile` the caller reads back is left untouched (native separators). +pub fn sk(outfile: &str, opts: &[&str]) -> String { + let sk_bin = SK.replace('\\', "/"); + let out = outfile.replace('\\', "/"); + format!( + "{}{} {} > {}.part; mv {}.part {}", + SKIM_SHELL_ENV_CLEAR, + sk_bin, + opts.join(" "), + out, + out, + out + ) +} + +/// Longest a single `zellij` CLI invocation may take before it is killed. Well +/// above a healthy call (tens of ms) but bounded, so a wedged Zellij server +/// (e.g. after a session was force-killed) surfaces as a retryable error +/// instead of blocking the test forever. +const ZELLIJ_CMD_TIMEOUT: Duration = Duration::from_secs(8); + +/// Overall wall-clock budget for a [`wait`] loop. Generous enough for a slow +/// machine under load, bounded so a genuinely failing assertion (or a dead +/// session) fails in seconds rather than minutes. +const WAIT_BUDGET: Duration = Duration::from_secs(20); + +/// Budget for the pane's *first* render (see [`ZellijController::wait_ready`]). +/// Larger than [`WAIT_BUDGET`]: on a cold macOS/Windows runner the Zellij server +/// extracts its bundled plugin assets and starts a shell on first use, which can +/// take considerably longer than a warm session's steady-state calls. Bounded so +/// a genuinely dead session still fails within a minute rather than hanging. +const FIRST_RENDER_BUDGET: Duration = Duration::from_secs(60); + +/// Cap on how many bytes of the Zellij client's PTY output we retain for +/// diagnostics (the tail is the most useful part — recent errors, the shell +/// prompt, etc.). +const CLIENT_OUTPUT_CAP: usize = 64 * 1024; + +/// Device Status Report requesting the cursor position (`ESC [ 6 n`). The Zellij +/// client emits this to probe the terminal; on the Windows ConPTY it *blocks* +/// until it receives the reply below (on Unix the size comes from the PTY ioctl, +/// so it never waits). We are the terminal, so the drain thread answers it. +const DSR_CPR_REQUEST: &[u8] = b"\x1b[6n"; + +/// Our reply to [`DSR_CPR_REQUEST`]: a Cursor Position Report placing the cursor +/// at the bottom-right of the 24x80 pane (`ESC [ 24 ; 80 R`), i.e. reporting a +/// 24x80 terminal to the client's size probe. +const CPR_REPLY: &[u8] = b"\x1b[24;80R"; + +/// How many times to (re)spawn the Zellij session before giving up. Zellij's +/// client/server handshake is occasionally racy at startup — the client dies +/// with "Received empty unknown from server" and the session never renders. This +/// is rare on Linux but frequent on the cold macOS/Windows runners, so a fresh +/// session is spawned instead of failing the test outright. +const SESSION_SPAWN_ATTEMPTS: usize = 4; + +/// Poll `pred` until it succeeds or [`WAIT_BUDGET`] elapses. On timeout the most +/// recent error returned by `pred` is surfaced, so a persistent failure keeps its +/// diagnostic cause. +pub fn wait(pred: F) -> Result +where + F: Fn() -> Result, +{ + let deadline = Instant::now() + WAIT_BUDGET; + loop { + match pred() { + Ok(t) => return Ok(t), + Err(e) => { + if Instant::now() >= deadline { + return Err(e); + } + } + } + sleep(Duration::from_millis(10)); + } +} + +/// Run a `Command`, returning its captured stdout/stderr. The child is killed and +/// a `TimedOut` error returned if it does not finish within `timeout`. Output +/// pipes are drained on threads so a chatty process cannot deadlock on a full +/// pipe, and the child/readers are always torn down before returning — including +/// on a `try_wait` failure. +/// +/// The exit status is intentionally *not* turned into an error: some `zellij +/// action` calls exit non-zero in transient states (e.g. while an inline `sk` +/// tears its viewport down) yet still return usable output, so callers rely on +/// [`ZellijController::action`]'s content checks instead. +fn output_with_timeout(mut cmd: Command, timeout: Duration) -> Result<(Vec, Vec)> { + let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let mut out = child.stdout.take().expect("piped stdout"); + let mut err = child.stderr.take().expect("piped stderr"); + let out_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + buf + }); + let err_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = err.read_to_end(&mut buf); + buf + }); + + let deadline = Instant::now() + timeout; + // Some(status) => the child exited; None => it was killed for timing out. + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + Ok(None) => sleep(Duration::from_millis(20)), + Err(e) => { + // Can't poll the child; don't leave it or the reader threads dangling. + let _ = child.kill(); + let _ = child.wait(); + let _ = out_handle.join(); + let _ = err_handle.join(); + return Err(e); + } + } + }; + + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + match status { + None => Err(std::io::Error::new(ErrorKind::TimedOut, "zellij command timed out")), + Some(_) => Ok((stdout, stderr)), + } +} + +/// A keystroke (or run of characters) to inject into the pane. Each variant +/// encodes to the raw terminal bytes a real terminal would send (see +/// [`Keys::encode`]): literal text, a single char, a modified key +/// (`Ctrl`/`Alt`), or a named special key. +pub enum Keys<'a> { + Str(&'a str), + Key(char), + Ctrl(&'a Keys<'a>), + Alt(&'a Keys<'a>), + Enter, + Tab, + BTab, + Left, + Right, + BSpace, + Up, + Down, + Escape, +} + +impl Display for Keys<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + use Keys::*; + match self { + Str(s) => write!(f, "{}", s), + Key(c) => write!(f, "{}", c), + Ctrl(k) => write!(f, "C-{}", k), + Alt(k) => write!(f, "M-{}", k), + Enter => write!(f, "Enter"), + Tab => write!(f, "Tab"), + BTab => write!(f, "BTab"), + Left => write!(f, "Left"), + Right => write!(f, "Right"), + BSpace => write!(f, "BSpace"), + Up => write!(f, "Up"), + Down => write!(f, "Down"), + Escape => write!(f, "Escape"), + } + } +} + +impl Keys<'_> { + /// The raw bytes a terminal would deliver for this key, appended to `out`. + fn encode(&self, out: &mut Vec) { + use Keys::*; + match self { + Str(s) => out.extend_from_slice(s.as_bytes()), + Key(c) => { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + Ctrl(inner) => { + let mut inner_bytes = Vec::new(); + inner.encode(&mut inner_bytes); + // Ctrl masks the low 5 bits of the (upper-cased) ASCII byte. + if let Some(&b) = inner_bytes.first() { + out.push(b.to_ascii_uppercase() & 0x1f); + } + } + Alt(inner) => { + out.push(0x1b); + inner.encode(out); + } + Enter => out.push(b'\r'), + Tab => out.push(b'\t'), + BTab => out.extend_from_slice(&[0x1b, b'[', b'Z']), + Left => out.extend_from_slice(&[0x1b, b'[', b'D']), + Right => out.extend_from_slice(&[0x1b, b'[', b'C']), + BSpace => out.push(0x7f), + Up => out.extend_from_slice(&[0x1b, b'[', b'A']), + Down => out.extend_from_slice(&[0x1b, b'[', b'B']), + Escape => out.push(0x1b), + } + } +} + +/// Drives a single `sk` process inside its own Zellij session. +/// +/// The `window` field is retained (holding the Zellij session name) so the +/// public shape matches the previous tmux controller. +pub struct ZellijController { + pub window: String, + pub tempdir: TempDir, + pub outfile: Option, + // The client process and its master PTY must stay alive for the session's + // lifetime: dropping the master hangs up the pane and tears the session + // down early. + master: Box, + child: Option>, + // Rolling tail of the Zellij client's PTY output, captured by the drain + // thread. Purely for diagnostics: when the pane fails to come up on a runner + // we can't reproduce locally, its tail (shell errors, plugin failures) is + // surfaced in the timeout error. + client_output: Arc>>, + // Set by the drain thread when the client's PTY reaches EOF, i.e. the Zellij + // client process has exited. Lets `wait_ready` abort a dead session fast + // (and respawn) instead of waiting out the whole render budget. + client_exited: Arc, +} + +fn io_err(e: E) -> std::io::Error { + std::io::Error::other(e.to_string()) +} + +impl ZellijController { + fn zellij_bin() -> std::path::PathBuf { + which("zellij").expect("Please install zellij (>= 0.44) to $PATH") + } + + /// Run `zellij ` with no session targeting and return stdout lines. + pub fn run(args: &[&str]) -> Result> { + let mut cmd = Command::new(Self::zellij_bin()); + cmd.env("ZELLIJ_SOCKET_DIR", zellij_socket_dir()?).args(args); + let (stdout, _) = output_with_timeout(cmd, ZELLIJ_CMD_TIMEOUT)?; + Ok(String::from_utf8_lossy(&stdout).lines().map(str::to_string).collect()) + } + + /// Run `zellij --session action ` and return stdout as a String. + /// + /// Returns an error while the session/pane is not yet available (or if the + /// call is killed for taking too long) so callers wrapped in [`wait`] retry + /// instead of observing a bogus "no session" message as pane content or + /// hanging on a wedged server. + fn action(&self, args: &[&str]) -> Result { + let mut cmd = Command::new(Self::zellij_bin()); + cmd.env("ZELLIJ_SOCKET_DIR", zellij_socket_dir()?) + .args(["--session", &self.window, "action"]) + .args(args); + let (stdout_bytes, stderr_bytes) = output_with_timeout(cmd, ZELLIJ_CMD_TIMEOUT)?; + let stdout = String::from_utf8_lossy(&stdout_bytes).to_string(); + let stderr = String::from_utf8_lossy(&stderr_bytes); + if stderr.contains("not found") || stdout.contains("no active session") { + return Err(std::io::Error::new(ErrorKind::NotConnected, "zellij session not ready")); + } + Ok(stdout) + } + + /// Inject raw terminal input bytes into the focused pane. + fn write_bytes(&self, bytes: &[u8]) -> Result<()> { + let mut args: Vec = vec!["write".to_string()]; + args.extend(bytes.iter().map(|b| b.to_string())); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + self.action(&refs)?; + Ok(()) + } + + /// Create a controller running its own Zellij session named after `name` + /// (plus a random suffix for uniqueness), with the pane's shell brought to a + /// clean, deterministic prompt. The session is torn down on drop. + /// + /// Zellij's startup handshake is occasionally racy (see + /// [`SESSION_SPAWN_ATTEMPTS`]); a session that dies before it renders is torn + /// down and a fresh one is spawned, up to that many attempts. + pub fn new_named(name: &str) -> Result { + // Keep session names short: Zellij caps them (~36 chars) and they also + // count against the unix socket path budget (see `zellij_socket_dir`). + // Drop non-alphanumerics (Zellij rejects most punctuation) and truncate; + // the random suffix added in `spawn_once` keeps them unique regardless. + let sanitized: String = name.chars().filter(char::is_ascii_alphanumeric).take(10).collect(); + + let mut last_err: Option = None; + for _ in 0..SESSION_SPAWN_ATTEMPTS { + match Self::spawn_once(&sanitized) { + Ok(controller) => return Ok(controller), + Err(e) => last_err = Some(e), + } + // The failed attempt's controller has been dropped here, tearing the + // dead session down before the next attempt spawns a fresh one. + } + Err(last_err.unwrap_or_else(|| std::io::Error::new(ErrorKind::Other, "failed to start zellij session"))) + } + + /// Spawn a single Zellij session + client and bring it to a ready prompt. + /// Returns the wired-up controller on success; on failure the transient + /// controller is dropped (cleaning up the session) and the error is returned + /// so [`new_named`](Self::new_named) can retry with a fresh session. + fn spawn_once(sanitized: &str) -> Result { + let suffix: String = rand::rng().sample_iter(&Alphanumeric).take(6).map(char::from).collect(); + // Kept short on purpose — see `new_named` and `zellij_socket_dir`. + let session = if sanitized.is_empty() { + format!("sk_{suffix}") + } else { + format!("sk_{sanitized}_{suffix}") + }; + + let tempdir = tempdir()?; + let config = tempdir.path().join("zellij.kdl"); + std::fs::write(&config, zellij_config())?; + + let pty = native_pty_system(); + let pair = pty + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(io_err)?; + + let mut cmd = CommandBuilder::new(Self::zellij_bin()); + cmd.args([ + "--config", + config.to_str().expect("config path is not valid UTF-8"), + "--session", + &session, + "attach", + "--create", + &session, + ]); + // Match the env hygiene the shell wrapper applies inline, so nothing + // leaks in from the outer test environment via the session server. + for var in SKIM_ENV_REMOVES { + cmd.env_remove(var); + } + // Keep the session's unix socket path short (see `zellij_socket_dir`). + cmd.env("ZELLIJ_SOCKET_DIR", zellij_socket_dir()?); + if let Ok(dir) = std::env::current_dir() { + cmd.cwd(dir); + } + + let child = pair.slave.spawn_command(cmd).map_err(io_err)?; + // The child keeps its own handles to the slave; drop ours. + drop(pair.slave); + + // Continuously drain the client's output or its PTY buffer fills and the + // Zellij client blocks (freezing rendering and input handling). We keep a + // bounded tail of that output for diagnostics (see `client_output`). + let mut reader = pair.master.try_clone_reader().map_err(io_err)?; + let mut writer = pair.master.take_writer().map_err(io_err)?; + let client_output = Arc::new(Mutex::new(Vec::new())); + let client_exited = Arc::new(AtomicBool::new(false)); + let sink = Arc::clone(&client_output); + let exited = Arc::clone(&client_exited); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + // Rolling window so a DSR request split across two reads is still + // matched. Only needs to span one marker's worth of bytes. + let mut window: Vec = Vec::new(); + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { + break; + } + let chunk = &buf[..n]; + if let Ok(mut tail) = sink.lock() { + tail.extend_from_slice(chunk); + let overflow = tail.len().saturating_sub(CLIENT_OUTPUT_CAP); + if overflow > 0 { + tail.drain(0..overflow); + } + } + // Reply to every cursor-position report the client requests, or + // it blocks forever on the Windows ConPTY and never renders. + window.extend_from_slice(chunk); + while let Some(pos) = window.windows(DSR_CPR_REQUEST.len()).position(|w| w == DSR_CPR_REQUEST) { + let _ = writer.write_all(CPR_REPLY); + let _ = writer.flush(); + window.drain(0..pos + DSR_CPR_REQUEST.len()); + } + // Retain a marker-minus-one tail in case the next request + // straddles the read boundary. + let keep = DSR_CPR_REQUEST.len().saturating_sub(1); + if window.len() > keep { + let cut = window.len() - keep; + window.drain(0..cut); + } + } + // PTY EOF: the Zellij client has exited (cleanly or by dying). + exited.store(true, Ordering::SeqCst); + }); + + let controller = Self { + window: session, + tempdir, + outfile: None, + master: pair.master, + child: Some(child), + client_output, + client_exited, + }; + controller.wait_ready()?; + Ok(controller) + } + + /// Like [`new_named`](Self::new_named) but with a random session name. + pub fn new() -> Result { + let name: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + Self::new_named(&name) + } + + /// Bytes we want the pane sized to for the tests (mirrors the initial + /// `openpty`). A screen dump reflects the *pane* geometry, which the Zellij + /// server derives from the attached client's terminal size. + const ROWS: u16 = 24; + const COLS: u16 = 80; + + /// Ask the client PTY to report `cols`×[`ROWS`]. Resizing (even to the same + /// size) makes `portable-pty` deliver a `SIGWINCH`/ConPTY resize to the + /// attached Zellij client, which then (re-)reports its geometry to the + /// server. On the macOS and Windows runners the client's *initial* size can + /// be lost, leaving the pane at zero size with nothing to render; nudging it + /// forces a non-zero geometry so the pane actually draws. + fn resize_client(&self, cols: u16) { + let _ = self.master.resize(PtySize { + rows: Self::ROWS, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + + /// A best-effort, human-readable tail of what the Zellij client has printed + /// to its PTY. Used to explain first-render timeouts on runners we can't + /// reproduce locally. + fn client_output_tail(&self) -> String { + self.client_output + .lock() + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default() + } + + /// Block until the session server and pane are up and a clean, deterministic + /// shell prompt is showing. + fn wait_ready(&self) -> Result<()> { + // Session server + pane are up once a screen dump comes back non-empty. + // Give this its own (longer) budget for cold-start runners, and on each + // empty poll nudge the client's terminal size so the server always has a + // non-zero pane geometry to render into. + let deadline = Instant::now() + FIRST_RENDER_BUDGET; + let mut wide = false; + loop { + if let Ok(dump) = self.action(&["dump-screen"]) { + if !dump.trim().is_empty() { + break; + } + } + // A dead client will never render; fail fast so `new_named` respawns. + if self.client_exited.load(Ordering::SeqCst) { + return Err(std::io::Error::new( + ErrorKind::ConnectionReset, + format!( + "zellij client exited before the pane rendered. output tail:\n{}", + self.client_output_tail() + ), + )); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + ErrorKind::WouldBlock, + format!( + "pane not rendered within {:?}. zellij client output tail:\n{}", + FIRST_RENDER_BUDGET, + self.client_output_tail() + ), + )); + } + // Toggle the width so every poll delivers a fresh resize event. + self.resize_client(if wide { Self::COLS + 1 } else { Self::COLS }); + wide = !wide; + sleep(Duration::from_millis(100)); + } + // Settle on the canonical size before the tests read the pane. + self.resize_client(Self::COLS); + + // Install a deterministic prompt and clear the screen. The keys are + // buffered by the pane PTY even if bash is still starting up. Re-send on + // each retry in case the very first keystrokes raced shell startup. + let deadline = Instant::now() + FIRST_RENDER_BUDGET; + loop { + self.write_bytes(format!("unset PROMPT_COMMAND HISTFILE HISTCONTROL; PS1='{PROMPT}'; clear\r").as_bytes())?; + let lines = self.capture()?; + if lines.first().is_some_and(|l| l.starts_with(PROMPT.trim_end())) + && !lines.iter().any(|l| l.contains("PROMPT_COMMAND")) + { + return Ok(()); + } + if self.client_exited.load(Ordering::SeqCst) { + return Err(std::io::Error::new( + ErrorKind::ConnectionReset, + format!( + "zellij client exited before the prompt came up. output tail:\n{}", + self.client_output_tail() + ), + )); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + ErrorKind::WouldBlock, + format!( + "shell prompt did not come up within {:?}. zellij client output tail:\n{}", + FIRST_RENDER_BUDGET, + self.client_output_tail() + ), + )); + } + sleep(Duration::from_millis(50)); + } + } + + /// Send a sequence of keystrokes to the pane, each encoded to the raw + /// terminal bytes a real terminal would deliver. + pub fn send_keys(&self, keys: &[Keys]) -> std::io::Result<()> { + print!("typing `"); + for key in keys { + let mut bytes = Vec::new(); + key.encode(&mut bytes); + self.write_bytes(&bytes)?; + print!("{}", key); + } + println!("`"); + Ok(()) + } + + /// Allocate a fresh temp file path inside this controller's tempdir. + pub fn tempfile(&self) -> Result { + Ok(NamedTempFile::new_in(&self.tempdir)? + .path() + .to_str() + .ok_or_else(|| std::io::Error::new(ErrorKind::InvalidData, "temp file path is not valid UTF-8"))? + .to_string()) + } + + /// Screen contents of the pane, most-recent (bottom) line first. + /// + /// The reversed ordering mirrors the old tmux harness so existing tests + /// index the same way: `capture()[0]` is the bottom line, where `sk` draws + /// its query prompt in the default (bottom-anchored) layout. + pub fn capture(&self) -> Result> { + let dump = wait(|| self.action(&["dump-screen"]))?; + Ok(Self::to_lines(&dump)) + } + + /// Like [`capture`], but preserves ANSI styling (colors / attributes). + pub fn capture_colored(&self) -> Result> { + let dump = wait(|| self.action(&["dump-screen", "--ansi"]))?; + Ok(Self::to_lines(&dump)) + } + + fn to_lines(dump: &str) -> Vec { + dump.trim() + .split('\n') + .map(str::to_string) + .rev() + .collect::>() + } + + /// Poll [`capture`](Self::capture) until `pred` accepts the screen contents, + /// erroring with the last captured screen if the budget elapses first. + pub fn until(&self, pred: F) -> std::io::Result<()> + where + F: Fn(&[String]) -> bool, + { + match wait(|| { + let lines = self.capture()?; + if pred(&lines) { + return Ok(true); + } + Err(std::io::Error::other("pred not matched")) + }) { + Ok(true) => Ok(()), + Ok(false) => Err(std::io::Error::other(self.capture()?.join("\n"))), + _ => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + self.capture()?.join("\n"), + )), + } + } + + /// Capture skim output without ANSI sequences + pub fn output(&self) -> Result> { + if let Some(ref outfile) = self.outfile { + self.output_from(outfile) + } else { + Err(std::io::Error::new( + ErrorKind::NotFound, + "You need to use start_sk to get an outfile", + )) + } + } + + /// Capture skim output from explicit outfile path + pub fn output_from(&self, outfile: &str) -> Result> { + wait(|| { + if Path::new(&outfile).exists() { + Ok(()) + } else { + Err(std::io::Error::new(ErrorKind::NotFound, "outfile does not exist yet")) + } + })?; + let mut string_lines = String::new(); + BufReader::new(File::open(outfile)?).read_to_string(&mut string_lines)?; + + let str_lines = string_lines.trim(); + Ok(str_lines + .split("\n") + .map(|s| s.to_string()) + .collect::>() + .into_iter() + .collect()) + } + + /// Launch `sk` in the pane with `opts`, optionally piping `stdin_cmd` into + /// it, recording the result file so [`output`](Self::output) can read it. + /// Returns the path of that output file. + pub fn start_sk(&mut self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result { + let outfile = self.tempfile()?; + let sk_cmd = sk(&outfile, opts); + let cmd = match stdin_cmd { + Some(s) => format!("{} | {}", s, sk_cmd), + None => sk_cmd, + }; + println!("--- starting up sk ---"); + self.send_keys(&[Keys::Str(&cmd), Keys::Enter])?; + println!("--- sk is running ---"); + self.outfile = Some(outfile.clone()); + Ok(outfile) + } +} + +impl Drop for ZellijController { + fn drop(&mut self) { + // Kill the client first, then reap it so it doesn't linger as a zombie + // (many controllers are created per test binary), then remove the session. + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = Self::run(&["delete-session", &self.window, "--force"]); + } +} +// ============================================================================ +// sk_test! - Macro for writing compact tmux-based integration tests +// ============================================================================ +// +// USAGE GUIDE +// ----------- +// +// 1. INPUT SYNTAX: +// - Echo string: "a\\nb\\nc" -> Runs: echo -n -e 'a\nb\nc' +// - Command: @cmd "seq 1 100" -> Runs: seq 1 100 (pipe to sk) +// +// 2. DSL SYNTAX (Only syntax supported): +// +// sk_test!(test_name, "input", &["--opts"], { +// @capture[0] eq(">"); // Wait until capture[0] == ">" +// @capture[1] trim().starts_with("3/3"); // Wait until capture[1].trim().starts_with("3/3") +// @capture[-1] eq("foo"); // Wait until last line == "foo" +// @capture[*] contains("bar"); // Wait until any line contains "bar" +// @output[0] eq("result"); // Wait until output[0] == "result" +// @output[-1] eq("last"); // Wait until output[-1] (last line) == "last" +// @output[*] starts_with("prefix"); // Wait until any output line starts with "prefix" +// @capture_colored[0] contains("\x1b"); // Wait until colored capture contains ANSI +// @lines |l| (l.len() > 5); // Complex assertion with closure +// @keys Enter, Tab; // Send multiple keys +// @dbg; // Debug print current capture +// }); +// +// NOTE: All methods use wait() for consistent retry behavior. Any ZellijController +// method that takes no args and returns Result> can be used: +// capture, output, capture_colored, etc. +// +// EXAMPLES +// -------- +// +// Example 1: Simple test with echo input (all methods wait/retry) +// sk_test!(simple, "a\\nb\\nc", &[], { +// @capture[0] eq(">"); // Waits until condition is met +// @keys Enter; +// @output[0] eq("a"); // Waits until output is available +// }); +// +// Example 2: Using command input with @cmd +// sk_test!(with_seq, @cmd "seq 1 10", &["--bind", "'ctrl-t:toggle-all'"], { +// @capture[0] eq(">"); +// @keys Ctrl(&Key('t')); +// @capture[2] eq(">>1"); +// }); +// +// Example 3: Complex closures with @lines +// sk_test!(complex, "apple\\nbanana", &[], { +// @lines |l| (l.len() > 4); +// @keys Str("ana"); +// @lines |l| (l.iter().any(|x| x.contains("banana"))); +// }); +// +// Example 4: Method chaining +// sk_test!(chaining, " foo \\n bar ", &[], { +// @capture[2] trim().eq("foo"); +// @keys Enter; +// @output[0] trim().eq("foo"); +// }); +// +// Example 5: Using wildcards and negative indices +// sk_test!(wildcards, "apple\\nbanana\\ncherry", &[], { +// @capture[*] contains("3/3"); // Any line contains "3/3" +// @capture[-1] starts_with(">"); // Last line starts with ">" +// @keys Str("ana"); +// @capture[*] contains("banana"); // Any line contains "banana" +// @keys Enter; +// @output[0] eq("banana"); // First output line +// @output[-1] eq("banana"); // Last output line +// @output[*] starts_with("b"); // Any output line starts with "b" +// }); +// +// Example 6: New array syntax test +// sk_test!(new_syntax_test, "foo\\nbar\\nbaz", &[], { +// @capture[0] starts_with(">"); +// @capture[1] contains("3/3"); +// @keys Enter; +// @output[0] eq("foo"); +// @output[- 1] eq("foo"); +// }); +// +// Example 7: Wildcard syntax test +// sk_test!(wildcard_syntax_test, "apple\\nbanana\\ncherry", &[], { +// @capture[*] contains("3/3"); +// @keys Str("ana"); +// @capture[*] contains("banana"); +// @keys Enter; +// @output[*] eq("banana"); +// }); +// +// Example 8: Comprehensive example showing all features +// sk_test!(comprehensive_example, "foo\\nbar\\nbaz\\nqux", &[], { +// // Positive index with simple method +// @capture[0] starts_with(">"); +// +// // Positive index with method chain +// @capture[1] trim().contains("4/4"); +// +// // Wildcard - check if any line matches +// @capture[*] contains("foo"); +// +// // Send keys +// @keys Str("ba"); +// +// // Negative index - last line +// @capture[- 1] contains("bar"); +// +// // Select first match +// @keys Enter; +// +// // Output assertions +// @output[0] eq("bar"); // First output line +// @output[- 1] eq("bar"); // Last output line +// @output[*] starts_with("b"); // Any output line starts with "b" +// }); +// +// Example 9: Using capture_colored for ANSI escape sequences +// sk_test!(ansi_test, @cmd "echo -e '\\x1b[31mred\\x1b[0m'", &["--ansi"], { +// @capture[*] contains("red"); +// @capture_colored[*] contains("\x1b[31m"); // Check for ANSI codes +// @keys Enter; +// }); +// +// DSL COMMAND REFERENCE +// --------------------- +// @METHOD[N] method_chain Wait until METHOD[N].method_chain is true (N = line number) +// @METHOD[-N] method_chain Wait until METHOD[-N].method_chain is true (negative index) +// @METHOD[*] method_chain Wait until any line matches (uses .iter().any()) +// where METHOD is any ZellijController method returning Result>: +// - capture: Wait until condition is true +// - output: Wait until condition is true +// - capture_colored: Wait until condition is true on colored capture +// All methods use wait() for consistent retry behavior +// @lines |l| (expr) Call tmux.until(|l| expr)? with closure +// @keys key1, key2 Send keys (automatically adds ?) +// @dbg Debug print current capture +// +// NOTES +// ----- +// - The `tmux` variable is implicitly available in DSL blocks +// - All variants automatically handle Result propagation and Ok(()) return +// - DSL closures must be wrapped in parentheses: |l| (expr) +// - Method chains support any String/&str method: eq(), starts_with(), contains(), trim(), etc. +// - You can chain methods: trim().starts_with("foo") +// - Negative indices work like Python: -1 is last element, -2 is second-to-last, etc. +// - ALL methods use wait() with retry logic - no immediate assertions +// - wait() retries every 10ms for up to 10 seconds before timing out +// +#[allow(unused_macros)] +macro_rules! sk_test { + // Standard variant with echo input: explicit variable name with block + ($name:tt, $input:expr, $options:expr, $tmux:ident => $content:block) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut $tmux = crate::common::zellij::ZellijController::new()?; + $tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; + + $content + + Ok(()) + } + }; + + // Standard variant with arbitrary command: use @cmd marker + ($name:tt, @cmd $cmd:expr, $options:expr, $tmux:ident => $content:block) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut $tmux = crate::common::zellij::ZellijController::new()?; + $tmux.start_sk(Some($cmd), $options)?; + + $content + + Ok(()) + } + }; + + // DSL variant with echo input + ($name:tt, $input:expr, $options:expr, { $($content:tt)* }) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut tmux = crate::common::zellij::ZellijController::new_named(stringify!($name))?; + tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?; + + sk_test!(@expand tmux; $($content)*); + + Ok(()) + } + }; + + // DSL variant with arbitrary command: use @cmd marker + ($name:tt, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => { + #[test] + #[allow(unused_variables)] + fn $name() -> std::io::Result<()> { + let mut tmux = crate::common::zellij::ZellijController::new_named(stringify!($name))?; + tmux.start_sk(Some($cmd), $options)?; + + sk_test!(@expand tmux; $($content)*); + + Ok(()) + } + }; + + // Token processing rules + (@expand $tmux:ident; ) => {}; + + // Generic method patterns - works with any ZellijController method + // @method[*] - check if any line matches (uses .iter().any()) + (@expand $tmux:ident; @ $method:ident [ * ] $($rest:tt)*) => { + sk_test!(@method_any_collect $tmux, $method, [] ; $($rest)*); + }; + + // @method[-idx] for negative index - supports arbitrary method chains (must come before positive) + (@expand $tmux:ident; @ $method:ident [ - $idx:literal ] $($rest:tt)*) => { + sk_test!(@method_neg_collect $tmux, $method, $idx, [] ; $($rest)*); + }; + + // @method[idx] for positive index - supports arbitrary method chains + (@expand $tmux:ident; @ $method:ident [ $idx:literal ] $($rest:tt)*) => { + sk_test!(@method_pos_collect $tmux, $method, $idx, [] ; $($rest)*); + }; + + // Collect tokens until semicolon for positive index - dispatches to wait or assert + (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_pos_dispatch $tmux, $method, $idx, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_pos_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for positive index - all methods use wait() + (@method_pos_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { + { + if crate::common::zellij::wait(|| { + let lines = $tmux.$method()?; + if lines.len() > $idx && lines[$idx].$($methods)* { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + let actual = if lines.len() > $idx { &lines[$idx] } else { "" }; + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) + )); + } + } + }; + + // Collect tokens until semicolon for negative index - dispatches to wait or assert + (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_neg_dispatch $tmux, $method, $idx, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_neg_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for negative index - all methods use wait() + (@method_neg_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => { + { + if crate::common::zellij::wait(|| { + let lines = $tmux.$method()?; + if $idx > 0 && lines.len() >= $idx { + let actual_idx = lines.len() - $idx; + if lines[actual_idx].$($methods)* { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "not enough lines")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + let actual_idx = lines.len().saturating_sub($idx); + let actual = if $idx > 0 && lines.len() >= $idx { &lines[actual_idx] } else { "" }; + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[-{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual) + )); + } + } + }; + + // Collect tokens until semicolon for wildcard [*] - dispatches to wait or assert + (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; ; $($rest:tt)*) => { + sk_test!(@method_any_dispatch $tmux, $method, [$($methods)*]); + sk_test!(@expand $tmux; $($rest)*); + }; + (@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => { + sk_test!(@method_any_collect $tmux, $method, [$($methods)* $next] ; $($rest)*); + }; + + // Dispatch for wildcard - all methods use wait() + (@method_any_dispatch $tmux:ident, $method:ident, [$($methods:tt)*]) => { + { + if crate::common::zellij::wait(|| { + let lines = $tmux.$method()?; + if lines.iter().any(|line| line.$($methods)*) { + Ok(true) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met")) + } + }).is_err() { + let lines = $tmux.$method().unwrap_or_default(); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Timed out waiting for {}[*] any line matching .{}, got: {:?}", stringify!($method), stringify!($($methods)*), lines) + )); + } + } + }; + + // @lines command for tmux.until with closure + (@expand $tmux:ident; @ lines | $param:ident | ( $($body:tt)* ) ; $($rest:tt)*) => { + $tmux.until(|$param| $($body)*)?; + sk_test!(@expand $tmux; $($rest)*); + }; + + // @keys command for send_keys - supports any number of keys + (@expand $tmux:ident; @ keys $($key:expr),+ ; $($rest:tt)*) => { + send_keys!($tmux, $($key),+)?; + sk_test!(@expand $tmux; $($rest)*); + }; + + // @dbg command for debug printing + (@expand $tmux:ident; @ dbg ; $($rest:tt)*) => { + match $tmux.capture() { + Ok(lines) => println!("DBG: capture: {:?}", lines), + Err(e) => println!("DBG: capture failed: {}", e), + } + match $tmux.output() { + Ok(lines) => println!("DBG: output: {:?}", lines), + Err(e) => println!("DBG: output failed: {}", e), + } + sk_test!(@expand $tmux; $($rest)*); + }; + + // Pass through regular Rust statements that access tmux (catch-all, must be last) + (@expand $tmux:ident; $stmt:stmt ; $($rest:tt)*) => { + #[allow(redundant_semicolons)] + { + $stmt; + sk_test!(@expand $tmux; $($rest)*); + } + }; + +} + +#[allow(unused_macros)] +macro_rules! assert_line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + { + if $tmux.until(|l| l.len() > $line_nr && l[$line_nr] $($expression)+).is_err() { + let lines = $tmux.capture().unwrap_or_default(); + let actual = if lines.len() > $line_nr { &lines[$line_nr] } else { "" }; + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "Timed out waiting for condition on line {}, got {} but expected it to {}", + $line_nr, + actual, + stringify!($($expression)+) + ), + )) + } else { + Ok(()) + } + }? + }; +} + +#[allow(unused_macros)] +macro_rules! send_keys { + ($tmux:ident, $($key:expr),+) => { + $tmux.send_keys(&[$($key),+]) + }; +} + +#[allow(unused_macros)] +macro_rules! assert_output_line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + let output = $tmux.output()?; + println!("Output: {output:?}"); + assert!(output[$line_nr] $($expression)+, "Timed out waiting for condition on output line {}, expected it to {}", $line_nr, stringify!($($expression)+)); + }; +} + +// Ultra-short aliases for compact test writing +// Usage: line!(t, 0 == ">") instead of assert_line!(t, 0 == ">") +#[allow(unused_macros)] +macro_rules! line { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + assert_line!($tmux, $line_nr $($expression)+) + }; +} + +#[allow(unused_macros)] +macro_rules! keys { + ($tmux:ident, $($key:expr),+) => { + send_keys!($tmux, $($key),+) + }; +} + +#[allow(unused_macros)] +macro_rules! out { + ($tmux:ident, $line_nr:literal $($expression:tt)+) => { + assert_output_line!($tmux, $line_nr $($expression)+) + }; +} diff --git a/tests/execute.rs b/tests/execute.rs index e352e9e1..fcbab705 100644 --- a/tests/execute.rs +++ b/tests/execute.rs @@ -1,4 +1,8 @@ #![allow(missing_docs, clippy::pedantic)] +// The Zellij harness is cross-platform, but this file stays unix-only for +// reasons unrelated to the multiplexer: it builds an executable bash helper via +// `std::os::unix::fs::PermissionsExt` and drives it with a POSIX shell script. +// (`#![cfg(unix)]` already covers both Linux and macOS.) #![cfg(unix)] #[allow(dead_code)] mod common; @@ -8,8 +12,8 @@ use std::io::{Read, Result, Write}; use std::os::unix::fs::PermissionsExt; use std::path::Path; -use common::tmux::Keys::*; -use common::tmux::{TmuxController, wait}; +use common::zellij::Keys::*; +use common::zellij::{ZellijController, wait}; /// Read the whole file at `path` into a `String`. fn read_file(path: &Path) -> Result { @@ -35,7 +39,7 @@ fn read_file(path: &Path) -> Result { /// exercised for both the fullscreen and inline layouts, since the post-execute /// repaint path differs from a normal render. fn run_interactive_execute(name: &str, extra_opts: &[&str]) -> Result<()> { - let mut tmux = TmuxController::new_named(name)?; + let mut tmux = ZellijController::new_named(name)?; let dir = tmux.tempdir.path().to_path_buf(); let script = dir.join("interactive.sh"); diff --git a/tests/unix.rs b/tests/interactive.rs similarity index 73% rename from tests/unix.rs rename to tests/interactive.rs index 2d7af923..698f9aa8 100644 --- a/tests/unix.rs +++ b/tests/interactive.rs @@ -1,14 +1,17 @@ #![allow(missing_docs, clippy::pedantic)] -#![cfg(unix)] +// Pure Zellij-harness e2e tests: they drive `sk` entirely through the terminal +// and depend on nothing OS-specific beyond the harness itself, which is +// cross-platform (see tests/common/zellij.rs). So these run on Linux, macOS and +// Windows. #[allow(dead_code)] #[macro_use] mod common; -use common::tmux::Keys::*; +use common::zellij::Keys::*; -sk_test!(tmux_version_long, "", &["--version"], { +sk_test!(sk_version_long, "", &["--version"], { @output[0] starts_with("sk "); }); -sk_test!(tmux_version_short, "", &["-V"], { +sk_test!(sk_version_short, "", &["-V"], { @output[0] starts_with("sk "); }); diff --git a/tests/listen.rs b/tests/listen.rs index 0dced7f0..420909b2 100644 --- a/tests/listen.rs +++ b/tests/listen.rs @@ -1,18 +1,21 @@ // TODO: automate listen tests on windows // Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing #![allow(missing_docs, clippy::pedantic)] +// The Zellij harness is cross-platform, but this file stays unix-only for +// reasons unrelated to the multiplexer: skim's `--listen`/`--remote` IPC binds a +// unix domain socket here. (`#![cfg(unix)]` already covers both Linux and macOS.) #![cfg(all(unix, feature = "listen"))] #[allow(dead_code)] #[macro_use] mod common; -use common::tmux::Keys::*; +use common::zellij::Keys::*; use rand::RngExt as _; use rand::distr::Alphabetic; use std::io::{Result, Write as _}; use std::process::{Child, Command, Stdio}; -use common::tmux::TmuxController; +use common::zellij::ZellijController; use crate::common::{SK, SKIM_ENV_REMOVES}; @@ -30,8 +33,8 @@ fn send(child: &mut Child, msg: &str) -> Result<()> { Ok(()) } -fn setup(name: &str, extra_args: &[&str]) -> Result<(TmuxController, Child)> { - let mut tmux = TmuxController::new_named(name)?; +fn setup(name: &str, extra_args: &[&str]) -> Result<(ZellijController, Child)> { + let mut tmux = ZellijController::new_named(name)?; let socket_name = format!( "sk-test-{name}{}", rand::rng() diff --git a/tests/popup.rs b/tests/popup.rs index 647ec9ef..09e9443f 100644 --- a/tests/popup.rs +++ b/tests/popup.rs @@ -1,16 +1,21 @@ #![allow(missing_docs, clippy::pedantic)] +// The Zellij harness is cross-platform, but this file stays unix-only for +// reasons unrelated to the multiplexer: it installs a mock `tmux`/`sh` binary +// (made executable via `std::os::unix::fs::PermissionsExt`) to assert how skim +// shells out for its `--tmux` popup feature. +// (`#![cfg(unix)]` already covers both Linux and macOS.) #![cfg(unix)] #[allow(dead_code)] mod common; -use common::tmux::Keys::*; -use common::tmux::TmuxController; +use common::zellij::Keys::*; +use common::zellij::ZellijController; use std::fs::{File, Permissions}; use std::io::{Read, Result, Write}; use std::os::unix::fs::PermissionsExt; use std::path::Path; -fn setup_tmux_mock(tmux: &TmuxController) -> Result { +fn setup_tmux_mock(tmux: &ZellijController) -> Result { let dir = &tmux.tempdir; let path = dir.path().join("tmux"); let mock_bin = Path::new(&path); @@ -24,8 +29,15 @@ echo \"$@\" > {} outfile.to_str().unwrap() ))?; std::fs::set_permissions(mock_bin, Permissions::from_mode(0o777))?; + // These tests exercise skim's *tmux* popup backend, but the harness runs + // skim inside a Zellij pane (so `$ZELLIJ` is set and Zellij would otherwise + // take priority). Unset `$ZELLIJ` and advertise a `$TMUX` so skim selects + // the tmux backend and shells out to the mock `tmux` installed on `$PATH`. tmux.send_keys(&[ - Str(&format!("export PATH={}:$PATH", dir.path().to_str().unwrap())), + Str(&format!( + "export PATH={}:$PATH; unset ZELLIJ; export TMUX=tmux-mock,0,0", + dir.path().to_str().unwrap() + )), Enter, ])?; @@ -44,7 +56,7 @@ fn get_tmux_cmd(outfile: &str) -> Result { /// sk process must not re-enter the popup path (infinite recursion guard). #[test] fn tmux_via_skim_default_options() -> Result<()> { - let tmux = TmuxController::new()?; + let tmux = ZellijController::new()?; let outfile = setup_tmux_mock(&tmux)?; // Run sk with SKIM_DEFAULT_OPTIONS=--tmux set inline so the popup path is exercised. let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {}", crate::common::SK); @@ -61,7 +73,7 @@ fn tmux_via_skim_default_options() -> Result<()> { #[test] fn tmux_vanilla() -> Result<()> { - let mut tmux = TmuxController::new()?; + let mut tmux = ZellijController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.start_sk(None, &["--tmux"])?; tmux.until(|_| Path::new(&outfile).exists())?; @@ -79,7 +91,7 @@ fn tmux_vanilla() -> Result<()> { #[test] fn tmux_output_format() -> Result<()> { - let mut tmux = TmuxController::new()?; + let mut tmux = ZellijController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.start_sk( None, @@ -107,7 +119,7 @@ fn tmux_output_format() -> Result<()> { #[test] fn tmux_stdin() -> Result<()> { - let mut tmux = TmuxController::new()?; + let mut tmux = ZellijController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.start_sk(Some("ls"), &["--tmux"])?; tmux.until(|_| Path::new(&outfile).exists())?; @@ -120,7 +132,7 @@ fn tmux_stdin() -> Result<()> { #[test] fn tmux_quote() -> Result<()> { - let mut tmux = TmuxController::new()?; + let mut tmux = ZellijController::new()?; let outfile = setup_tmux_mock(&tmux)?; tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?; tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?;