diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-19 12:31:17 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-19 12:31:17 -0500 |
| commit | 924fec1f00e7ef5e497575488701e8c9eb2606f0 (patch) | |
| tree | 9c0df313352ab01bd608fd408871ef7de418a39c /tests | |
| parent | 5d8d9df7d1b95c1a6a7bf25ddf57652c86f110b3 (diff) | |
| download | archsetup-924fec1f00e7ef5e497575488701e8c9eb2606f0.tar.gz archsetup-924fec1f00e7ef5e497575488701e8c9eb2606f0.zip | |
feat(tmux-util): add ls subcommand
tmux-util ls is an opinionated replacement for `tmux ls` with columns for state (attached/detached), name, idle time (humanized), window count, and the current pane's cwd (tilde-fied if it sits under $HOME).
The cwd query goes through `tmux display -p -t <session> '#{pane_current_path}'`, which returns the cwd of the active pane of the active window. That's close enough to "what the session is about" for a one-line summary.
Idle calculation reads `date +%s` by default and accepts an override via the TMUX_UTIL_NOW env var so tests can pin "now" to a known epoch.
12 new tests cover Normal cases (attached / detached, multiple sessions) and Boundary cases (no sessions, idle exactly at minute / hour / day boundaries, $HOME tilde). One existing dispatch test got reworked because the original stub target (`ls`) is no longer unimplemented. Total suite is 26 tests, all green.
The fake-tmux harness picked up two things along the way: real format-string parsing for `list-sessions -F` and a new handler for `display -p`. The state file format extended to include activity epoch, window count, and cwd, with sensible defaults for older 3-tuple test inputs so the reap tests keep passing untouched.
Diffstat (limited to 'tests')
| -rwxr-xr-x | tests/tmux-util/fake-tmux | 65 | ||||
| -rw-r--r-- | tests/tmux-util/test_tmux_util.py | 142 |
2 files changed, 187 insertions, 20 deletions
diff --git a/tests/tmux-util/fake-tmux b/tests/tmux-util/fake-tmux index f6217bb..ba2d5cc 100755 --- a/tests/tmux-util/fake-tmux +++ b/tests/tmux-util/fake-tmux @@ -1,9 +1,11 @@ -#!/bin/sh +#!/bin/bash # Fake tmux for testing tmux-util. # # State file: $FAKE_TMUX_DIR/sessions.txt -# One line per session, space-separated: <name> <attached> <pids_csv> [last_activity_epoch] +# One line per session, space-separated: +# <name> <attached> <pids_csv> [<activity_epoch> [<windows> [<cwd>]]] # pids_csv is a comma-separated list of pane PIDs (or '-' for none) +# cwd cannot contain spaces in test data. # # Log file: $FAKE_TMUX_DIR/calls.log # Each invocation appended as a single line: tmux <args> @@ -19,19 +21,36 @@ printf 'tmux %s\n' "$*" >> "$LOG" cmd="$1" shift -# Helper: read state file, ignoring blank lines read_state() { [ -f "$STATE" ] || return 0 grep -v '^[[:space:]]*$' "$STATE" || true } +# Render a tmux format string against one session's fields. +# Args: format, name, attached, activity, windows, cwd +render_format() { + local out="$1" name="$2" attached="$3" activity="$4" windows="$5" cwd="$6" + out="${out//\#\{session_name\}/$name}" + out="${out//\#\{session_attached\}/$attached}" + out="${out//\#\{session_activity\}/$activity}" + out="${out//\#\{session_windows\}/$windows}" + out="${out//\#\{pane_current_path\}/$cwd}" + echo "$out" +} + case "$cmd" in list-sessions) - # Format string is ignored; we always emit "<name> <attached>" because - # that's the only format tmux-util uses against list-sessions. - read_state | while IFS=' ' read -r name attached pids _rest; do + fmt="" + while [ "$#" -gt 0 ]; do + case "$1" in + -F) shift; fmt="${1:-}"; shift ;; + *) shift ;; + esac + done + [ -n "$fmt" ] || fmt='#{session_name} #{session_attached}' + read_state | while IFS=' ' read -r name attached pids activity windows cwd; do [ -n "$name" ] || continue - echo "$name $attached" + render_format "$fmt" "$name" "$attached" "${activity:-0}" "${windows:-1}" "${cwd:-/tmp}" done ;; list-panes) @@ -51,6 +70,25 @@ case "$cmd" in fi done ;; + display) + fmt="" + target="" + while [ "$#" -gt 0 ]; do + case "$1" in + -p) shift ;; + -t) shift; target="$1"; shift ;; + *) fmt="$1"; shift ;; + esac + done + while IFS=' ' read -r name attached pids activity windows cwd; do + [ -n "$name" ] || continue + if [ "$name" = "$target" ]; then + render_format "$fmt" "$name" "$attached" "${activity:-0}" "${windows:-1}" "${cwd:-/tmp}" + exit 0 + fi + done < "$STATE" + exit 1 + ;; has-session) session="" while [ "$#" -gt 0 ]; do @@ -76,14 +114,11 @@ case "$cmd" in done tmp="$STATE.tmp" : > "$tmp" - while IFS=' ' read -r name attached pids rest; do - [ -n "$name" ] || continue - if [ "$name" != "$session" ]; then - if [ -n "$rest" ]; then - printf '%s %s %s %s\n' "$name" "$attached" "$pids" "$rest" >> "$tmp" - else - printf '%s %s %s\n' "$name" "$attached" "$pids" >> "$tmp" - fi + while IFS= read -r line; do + [ -n "$line" ] || continue + first="${line%% *}" + if [ "$first" != "$session" ]; then + printf '%s\n' "$line" >> "$tmp" fi done < "$STATE" mv "$tmp" "$STATE" diff --git a/tests/tmux-util/test_tmux_util.py b/tests/tmux-util/test_tmux_util.py index 8060ccc..283c3c7 100644 --- a/tests/tmux-util/test_tmux_util.py +++ b/tests/tmux-util/test_tmux_util.py @@ -50,11 +50,21 @@ class TmuxUtilHarness(unittest.TestCase): shutil.rmtree(self.tmp, ignore_errors=True) def set_sessions(self, sessions): - """sessions: list of (name, attached, [pane_pids]).""" + """sessions: list of tuples (name, attached, pids[, activity, windows, cwd]). + + Defaults for trailing fields: activity=0, windows=1, cwd='/tmp'. + cwd must not contain spaces (the fake's state file is space-delimited). + """ with open(self.state_file, "w") as f: - for name, attached, pids in sessions: + for s in sessions: + name = s[0] + attached = s[1] + pids = s[2] + activity = s[3] if len(s) > 3 else 0 + windows = s[4] if len(s) > 4 else 1 + cwd = s[5] if len(s) > 5 else "/tmp" pids_csv = ",".join(str(p) for p in pids) if pids else "-" - f.write(f"{name} {attached} {pids_csv}\n") + f.write(f"{name} {attached} {pids_csv} {activity} {windows} {cwd}\n") def run_script(self, *args, env_extra=None): env = os.environ.copy() @@ -118,8 +128,8 @@ class TestDispatch(TmuxUtilHarness): self.assertIn("Usage: tmux-util", result.stderr) def test_unimplemented_subcommand_exits_nonzero(self): - # ls/go/pick/find/rename will land later; for now they stub out. - result = self.run_script("ls") + # go/pick/find/rename stub out for now; pick one to confirm the stub path. + result = self.run_script("pick") self.assertNotEqual(result.returncode, 0) self.assertIn("not implemented yet", result.stderr) @@ -242,5 +252,127 @@ class TestReapBoundary(TmuxUtilHarness): self.assertEqual(self.kill_calls(), []) +# ----------------------------------------------------------------------------- +# ls — Normal cases +# ----------------------------------------------------------------------------- + +class TestLsNormal(TmuxUtilHarness): + + def test_ls_prints_header_and_columns_for_attached_session(self): + # activity=950, now=1000 → idle 50s. cwd=/tmp/work → unchanged (not under HOME). + self.set_sessions([ + ("work", 1, [101], 950, 3, "/tmp/work"), + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertEqual(result.returncode, 0, msg=result.stderr) + out = result.stdout + self.assertIn("STATE", out) + self.assertIn("NAME", out) + self.assertIn("IDLE", out) + self.assertIn("WINDOWS", out) + self.assertIn("CWD", out) + self.assertIn("attached", out) + self.assertIn("work", out) + self.assertIn("50s", out) + self.assertIn("3", out) + self.assertIn("/tmp/work", out) + + def test_ls_shows_detached_state_for_unattached_session(self): + self.set_sessions([ + ("idle", 0, [201], 900, 1, "/tmp/idle"), + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertEqual(result.returncode, 0) + self.assertIn("detached", result.stdout) + self.assertIn("idle", result.stdout) + + def test_ls_lists_multiple_sessions(self): + self.set_sessions([ + ("foo", 1, [101], 990, 2, "/tmp/foo"), + ("bar", 0, [201], 900, 1, "/tmp/bar"), + ("baz", 0, [301], 800, 4, "/tmp/baz"), + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertEqual(result.returncode, 0) + for name in ("foo", "bar", "baz"): + self.assertIn(name, result.stdout) + + +# ----------------------------------------------------------------------------- +# ls — Boundary cases +# ----------------------------------------------------------------------------- + +class TestLsBoundary(TmuxUtilHarness): + + def test_ls_no_sessions_prints_message(self): + self.set_sessions([]) + result = self.run_script("ls") + self.assertEqual(result.returncode, 0) + self.assertIn("No tmux sessions", result.stdout) + + def test_ls_idle_zero_seconds(self): + self.set_sessions([ + ("now", 1, [101], 1000, 1, "/tmp/now"), + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertEqual(result.returncode, 0) + self.assertIn("0s", result.stdout) + + def test_ls_idle_under_minute_shows_seconds(self): + self.set_sessions([ + ("s59", 0, [201], 941, 1, "/tmp/s"), # 59s ago + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertIn("59s", result.stdout) + + def test_ls_idle_exactly_one_minute_shows_minutes(self): + self.set_sessions([ + ("m1", 0, [201], 940, 1, "/tmp/m"), # 60s ago + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertIn("1m", result.stdout) + + def test_ls_idle_under_hour_shows_minutes(self): + self.set_sessions([ + ("m45", 0, [201], 0, 1, "/tmp/m"), # 2700s = 45m + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "2700"}) + self.assertIn("45m", result.stdout) + + def test_ls_idle_exactly_one_hour_shows_hours(self): + self.set_sessions([ + ("h1", 0, [201], 0, 1, "/tmp/h"), # 3600s = 1h + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "3600"}) + self.assertIn("1h", result.stdout) + + def test_ls_idle_under_day_shows_hours(self): + self.set_sessions([ + ("h23", 0, [201], 0, 1, "/tmp/h"), # 23 * 3600 = 82800s + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "82800"}) + self.assertIn("23h", result.stdout) + + def test_ls_idle_exactly_one_day_shows_days(self): + self.set_sessions([ + ("d1", 0, [201], 0, 1, "/tmp/d"), # 86400s = 1d + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "86400"}) + self.assertIn("1d", result.stdout) + + def test_ls_tilde_fies_home_paths(self): + # cwd inside $HOME should render as ~/... + home = os.environ.get("HOME", "/home/cjennings") + cwd_under_home = home + "/code/foo" + self.set_sessions([ + ("h", 1, [101], 990, 1, cwd_under_home), + ]) + result = self.run_script("ls", env_extra={"TMUX_UTIL_NOW": "1000"}) + self.assertEqual(result.returncode, 0) + self.assertIn("~/code/foo", result.stdout) + # The absolute path under HOME should NOT appear in raw form + self.assertNotIn(cwd_under_home, result.stdout) + + if __name__ == "__main__": unittest.main() |
