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:
LoricAndre 2026-07-23 18:32:29 +02:00 committed by GitHub
parent 9016a712a2
commit 88ce5b97ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1333 additions and 769 deletions

View file

@ -1,9 +1,8 @@
experimental = ["setup-scripts", "wrapper-scripts"] experimental = ["wrapper-scripts"]
[scripts.setup.stop-tmux] # The end-to-end tests drive `sk` inside a Zellij session that each test creates
command = "sh -c 'tmux kill-session -t skim_e2e || true'" # and tears down itself (see tests/common/zellij.rs), so — unlike the previous
[scripts.setup.start-tmux] # tmux-based harness — no shared multiplexer session needs to be set up here.
command = "tmux new-session -d -s skim_e2e -n skim_e2e"
# Valgrind wrapper for memory leak detection # Valgrind wrapper for memory leak detection
[scripts.wrapper.valgrind] [scripts.wrapper.valgrind]
@ -16,17 +15,28 @@ command = [
"--suppressions=.config/valgrind.supp" "--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] [profile.default]
fail-fast = false fail-fast = false
retries = 2 retries = 2
[[profile.default.scripts]] [[profile.default.overrides]]
platform = "cfg(unix)" filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)'
setup = ["stop-tmux", "start-tmux"] test-group = 'e2e'
[profile.default.junit] [profile.default.junit]
path = "junit.xml" path = "junit.xml"
[profile.ci] [profile.ci]
retries = 9 retries = 9
[[profile.ci.overrides]]
filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)'
test-group = 'e2e'
# Valgrind profile for memory leak detection # Valgrind profile for memory leak detection
# Usage: cargo nextest run --profile valgrind # Usage: cargo nextest run --profile valgrind
@ -39,7 +49,6 @@ retries = 2
test-threads = 1 # Run tests serially to avoid interleaved valgrind output test-threads = 1 # Run tests serially to avoid interleaved valgrind output
[[profile.valgrind.scripts]] [[profile.valgrind.scripts]]
platform = "cfg(unix)" platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]
run-wrapper = "valgrind" run-wrapper = "valgrind"
# ThreadSanitizer profile for detecting data races and thread issues # ThreadSanitizer profile for detecting data races and thread issues
@ -68,6 +77,3 @@ run-wrapper = "valgrind"
fail-fast = false fail-fast = false
retries = 3 retries = 3
test-threads = 1 # TSan requires running tests serially test-threads = 1 # TSan requires running tests serially
[[profile.tsan.scripts]]
platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]

View file

@ -19,7 +19,14 @@ concurrency:
jobs: jobs:
nextest: nextest:
runs-on: ${{matrix.runner}} 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: 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 matrix: &matrix
build: [linux, macos, windows] build: [linux, macos, windows]
include: include:
@ -32,21 +39,39 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- &linux-deps - &zellij-install
name: "[linux] Install dependencies" # 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: | run: |
sudo apt-get install tmux $ErrorActionPreference = 'Stop'
tmux -V winget install --exact --id Zellij.Zellij --version 0.44.3 --source winget --accept-source-agreements --accept-package-agreements --disable-interactivity
locale # The winget package is an MSI that installs zellij and adds its dir to
if: runner.os == 'Linux' # the machine PATH in the registry. Neither the current process nor
- name: "[macos] Install dependencies" # GITHUB_PATH sees that until reloaded, so refresh PATH from the
run: | # registry (with a Program Files fallback), then expose zellij's dir to
brew install tmux # later steps.
tmux -V $env:PATH = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') + ';' + $env:PATH
locale $exe = (Get-Command zellij.exe -ErrorAction SilentlyContinue).Source
if: runner.os == 'macOS' if (-not $exe) { $exe = (Get-ChildItem 'C:\Program Files' -Recurse -Filter zellij.exe -ErrorAction SilentlyContinue | Select-Object -First 1).FullName }
env: if (-not $exe) { throw "zellij.exe not found after winget install" }
HOMEBREW_NO_AUTO_UPDATE: 1 $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 - &checkout
name: Checkout repository name: Checkout repository
@ -93,7 +118,7 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- *linux-deps - *zellij-install
- *checkout - *checkout
- *toolchain - *toolchain
- *nextest-install - *nextest-install

View file

@ -5,7 +5,7 @@
- Run: `cargo run [--release]` - Run: `cargo run [--release]`
- Test (all): `cargo nextest run` - Test (all): `cargo nextest run`
- Test (single): `cargo nextest test_name` - 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` - Memory leak detection: `cargo nextest run --profile valgrind`
- Thread leak/race detection: - Thread leak/race detection:
1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu` 1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu`
@ -39,11 +39,30 @@
## Testing ## Testing
This application can be tested by : The end-to-end tests drive a real `sk` process through a terminal, using the
- 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. Zellij-backed harness in `tests/common/zellij.rs` (`ZellijController` + the
- 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` `sk_test!` DSL). It requires `zellij` (>= 0.44) and `bash` on `$PATH`. The
- sending the command to run and input using `tmux send-keys -t <window name> <keys>` harness is cross-platform (Linux, macOS and Windows). The pure-harness tests in
- 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>` `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 ## Insta Snapshot Tests

View file

@ -153,7 +153,8 @@ skim/ ← workspace root
│ └── util.rs ← cursor helpers, style merging │ └── util.rs ← cursor helpers, style merging
├── tests/ ← integration & snapshot tests ├── tests/ ← integration & snapshot tests
│ ├── common/ │ ├── 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 │ ├── snapshots/ ← committed .snap files
│ ├── ansi.rs ← ANSI rendering tests │ ├── ansi.rs ← ANSI rendering tests
│ ├── options.rs ← option coverage tests │ ├── options.rs ← option coverage tests

View file

@ -29,7 +29,8 @@ auto-release:
test target="": test target="":
cargo test --doc cargo test --doc
cargo nextest run {{ target }} 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": bench-plot bins="./target/release/sk sk fzf":
#!/usr/bin/env bash #!/usr/bin/env bash

View file

@ -1,8 +1,9 @@
#[macro_use] #[macro_use]
pub mod insta; 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] #[macro_use]
#[cfg(unix)] pub mod zellij;
pub mod tmux;
/// Raw binary path. Use `Command::new(SK)` to spawn directly; apply /// Raw binary path. Use `Command::new(SK)` to spawn directly; apply
/// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed. /// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed.

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,8 @@
#![allow(missing_docs, clippy::pedantic)] #![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)] #![cfg(unix)]
#[allow(dead_code)] #[allow(dead_code)]
mod common; mod common;
@ -8,8 +12,8 @@ use std::io::{Read, Result, Write};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::Path; use std::path::Path;
use common::tmux::Keys::*; use common::zellij::Keys::*;
use common::tmux::{TmuxController, wait}; use common::zellij::{ZellijController, wait};
/// Read the whole file at `path` into a `String`. /// Read the whole file at `path` into a `String`.
fn read_file(path: &Path) -> Result<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 /// exercised for both the fullscreen and inline layouts, since the post-execute
/// repaint path differs from a normal render. /// repaint path differs from a normal render.
fn run_interactive_execute(name: &str, extra_opts: &[&str]) -> Result<()> { 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 dir = tmux.tempdir.path().to_path_buf();
let script = dir.join("interactive.sh"); let script = dir.join("interactive.sh");

View file

@ -1,14 +1,17 @@
#![allow(missing_docs, clippy::pedantic)] #![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)] #[allow(dead_code)]
#[macro_use] #[macro_use]
mod common; 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 "); @output[0] starts_with("sk ");
}); });
sk_test!(tmux_version_short, "", &["-V"], { sk_test!(sk_version_short, "", &["-V"], {
@output[0] starts_with("sk "); @output[0] starts_with("sk ");
}); });

View file

@ -1,18 +1,21 @@
// TODO: automate listen tests on windows // TODO: automate listen tests on windows
// Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing // Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing
#![allow(missing_docs, clippy::pedantic)] #![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"))] #![cfg(all(unix, feature = "listen"))]
#[allow(dead_code)] #[allow(dead_code)]
#[macro_use] #[macro_use]
mod common; mod common;
use common::tmux::Keys::*; use common::zellij::Keys::*;
use rand::RngExt as _; use rand::RngExt as _;
use rand::distr::Alphabetic; use rand::distr::Alphabetic;
use std::io::{Result, Write as _}; use std::io::{Result, Write as _};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use common::tmux::TmuxController; use common::zellij::ZellijController;
use crate::common::{SK, SKIM_ENV_REMOVES}; use crate::common::{SK, SKIM_ENV_REMOVES};
@ -30,8 +33,8 @@ fn send(child: &mut Child, msg: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn setup(name: &str, extra_args: &[&str]) -> Result<(TmuxController, Child)> { fn setup(name: &str, extra_args: &[&str]) -> Result<(ZellijController, Child)> {
let mut tmux = TmuxController::new_named(name)?; let mut tmux = ZellijController::new_named(name)?;
let socket_name = format!( let socket_name = format!(
"sk-test-{name}{}", "sk-test-{name}{}",
rand::rng() rand::rng()

View file

@ -1,16 +1,21 @@
#![allow(missing_docs, clippy::pedantic)] #![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)] #![cfg(unix)]
#[allow(dead_code)] #[allow(dead_code)]
mod common; mod common;
use common::tmux::Keys::*; use common::zellij::Keys::*;
use common::tmux::TmuxController; use common::zellij::ZellijController;
use std::fs::{File, Permissions}; use std::fs::{File, Permissions};
use std::io::{Read, Result, Write}; use std::io::{Read, Result, Write};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::Path; 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 dir = &tmux.tempdir;
let path = dir.path().join("tmux"); let path = dir.path().join("tmux");
let mock_bin = Path::new(&path); let mock_bin = Path::new(&path);
@ -24,8 +29,15 @@ echo \"$@\" > {}
outfile.to_str().unwrap() outfile.to_str().unwrap()
))?; ))?;
std::fs::set_permissions(mock_bin, Permissions::from_mode(0o777))?; 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(&[ 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, 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). /// sk process must not re-enter the popup path (infinite recursion guard).
#[test] #[test]
fn tmux_via_skim_default_options() -> Result<()> { fn tmux_via_skim_default_options() -> Result<()> {
let tmux = TmuxController::new()?; let tmux = ZellijController::new()?;
let outfile = setup_tmux_mock(&tmux)?; let outfile = setup_tmux_mock(&tmux)?;
// Run sk with SKIM_DEFAULT_OPTIONS=--tmux set inline so the popup path is exercised. // 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); let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {}", crate::common::SK);
@ -61,7 +73,7 @@ fn tmux_via_skim_default_options() -> Result<()> {
#[test] #[test]
fn tmux_vanilla() -> Result<()> { fn tmux_vanilla() -> Result<()> {
let mut tmux = TmuxController::new()?; let mut tmux = ZellijController::new()?;
let outfile = setup_tmux_mock(&tmux)?; let outfile = setup_tmux_mock(&tmux)?;
tmux.start_sk(None, &["--tmux"])?; tmux.start_sk(None, &["--tmux"])?;
tmux.until(|_| Path::new(&outfile).exists())?; tmux.until(|_| Path::new(&outfile).exists())?;
@ -79,7 +91,7 @@ fn tmux_vanilla() -> Result<()> {
#[test] #[test]
fn tmux_output_format() -> Result<()> { fn tmux_output_format() -> Result<()> {
let mut tmux = TmuxController::new()?; let mut tmux = ZellijController::new()?;
let outfile = setup_tmux_mock(&tmux)?; let outfile = setup_tmux_mock(&tmux)?;
tmux.start_sk( tmux.start_sk(
None, None,
@ -107,7 +119,7 @@ fn tmux_output_format() -> Result<()> {
#[test] #[test]
fn tmux_stdin() -> Result<()> { fn tmux_stdin() -> Result<()> {
let mut tmux = TmuxController::new()?; let mut tmux = ZellijController::new()?;
let outfile = setup_tmux_mock(&tmux)?; let outfile = setup_tmux_mock(&tmux)?;
tmux.start_sk(Some("ls"), &["--tmux"])?; tmux.start_sk(Some("ls"), &["--tmux"])?;
tmux.until(|_| Path::new(&outfile).exists())?; tmux.until(|_| Path::new(&outfile).exists())?;
@ -120,7 +132,7 @@ fn tmux_stdin() -> Result<()> {
#[test] #[test]
fn tmux_quote() -> Result<()> { fn tmux_quote() -> Result<()> {
let mut tmux = TmuxController::new()?; let mut tmux = ZellijController::new()?;
let outfile = setup_tmux_mock(&tmux)?; let outfile = setup_tmux_mock(&tmux)?;
tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?; tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?;
tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?; tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?;