mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
test: replace tmux e2e harness with cross-platform Zellij harness (#1139)
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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>': 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/<protocol>/<session>`, and a unix socket path is length-capped by the OS (~104 bytes on macOS). Zellij's default base is `$TMPDIR/zellij-<uid>`; 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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" / "<no line>" 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9016a712a2
commit
88ce5b97ac
|
|
@ -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"]
|
||||
|
|
|
|||
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
AGENTS.md
31
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 <session name> -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 <window name> -t <session name>` and configuring the pane naming using `tmux set-window-option -t <window name> pane-base-index 0`
|
||||
- sending the command to run and input using `tmux send-keys -t <window name> <keys>`
|
||||
- when ready, capturing the window using `tmux capture-pane -b <window name> -t <window name>.0` and then saving the capture to a file using `tmux save-buffer -b <window name> <output file>`
|
||||
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 <session>` (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 <session> action write <bytes...>` to inject keystrokes.
|
||||
- `zellij --session <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
justfile
3
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<F, T>(pred: F) -> Result<T>
|
||||
where
|
||||
F: Fn() -> Result<T>,
|
||||
{
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<Vec<String>> {
|
||||
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::<Vec<String>>();
|
||||
Ok(output[0..output.len() - 1].to_vec())
|
||||
}
|
||||
|
||||
pub fn new_named(name: &str) -> Result<Self> {
|
||||
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::<String>()
|
||||
);
|
||||
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<Self> {
|
||||
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<String> {
|
||||
Ok(NamedTempFile::new_in(&self.tempdir)?
|
||||
.path()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// Returns the lines in reverted order
|
||||
pub fn capture(&self) -> Result<Vec<String>> {
|
||||
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::<Vec<String>>()
|
||||
.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<Vec<String>> {
|
||||
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::<Vec<String>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn until<F>(&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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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::<Vec<String>>()
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn start_sk(&mut self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result<String> {
|
||||
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<Vec<String>> 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<Vec<String>>:
|
||||
// - 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 { "<no line>" };
|
||||
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 { "<no line>" };
|
||||
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 { "<no line>" };
|
||||
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)+)
|
||||
};
|
||||
}
|
||||
1201
tests/common/zellij.rs
Normal file
1201
tests/common/zellij.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<String> {
|
||||
|
|
@ -35,7 +39,7 @@ fn read_file(path: &Path) -> Result<String> {
|
|||
/// 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");
|
||||
|
|
|
|||
|
|
@ -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 ");
|
||||
});
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
fn setup_tmux_mock(tmux: &ZellijController) -> Result<String> {
|
||||
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<String> {
|
|||
/// 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])?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue