nnn_runner.sh (12741B)
1 #!/bin/sh 2 # 3 # POSIX test wrapper for nnn (no tmux dependency) 4 # 5 # Runs nnn inside a pty (via the `script` command) and feeds it keystrokes 6 # from an input file, one input per line. Captures terminal output for 7 # verification. 8 # 9 # Dependencies: script (from util-linux / POSIX), mkfifo 10 # 11 # Usage: 12 # ./misc/test/nnn_runner.sh [OPTIONS] <input-file> [nnn-args ...] 13 # 14 # Options: 15 # -e <path> Path to nnn binary (default: ./nnn) 16 # -d <dir> Working directory for nnn (default: temporary dir) 17 # -o <dir> Output directory for snapshots (default: /tmp/nnn-test.<pid>) 18 # -t <ms> Delay in milliseconds between inputs (default: 200) 19 # -v Verbose: print each input as it is sent 20 # -h Show this help 21 # 22 # Input file format: 23 # One input per line. Blank lines and lines starting with '#' are skipped. 24 # Lines starting with '@' are directives: 25 # @sleep <ms> Extra delay in milliseconds 26 # @snapshot <label> Capture a named snapshot 27 # @assert <string> Fail if <string> is NOT in terminal output (full history) 28 # @refute <string> Fail if <string> IS in output since last @mark 29 # @mark Set checkpoint; subsequent @refute checks only output 30 # produced after this point 31 # 32 # Key encoding: 33 # >text Send literal text (no newline appended) 34 # Enter Send carriage return (\r) 35 # Escape Send ESC (\033) 36 # Tab Send tab (\t) 37 # Space Send space 38 # Up Send arrow up (\033[A) 39 # Down Send arrow down (\033[B) 40 # Right Send arrow right (\033[C) 41 # Left Send arrow left (\033[D) 42 # BSpace Send backspace (\177) 43 # C-<x> Send Ctrl+<x> (e.g. C-a sends \001) 44 # Any other single character is sent as-is (e.g. j, k, q, /, d) 45 # 46 # Example input file: 47 # # Navigate down and open a file 48 # j 49 # j 50 # Enter 51 # @sleep 500 52 # @snapshot after-open 53 # q 54 # 55 # Exit codes: 56 # 0 All assertions passed 57 # 1 Usage error / missing dependencies 58 # 2 Assertion failure 59 60 set -e 61 62 # ---------- defaults ---------- 63 NNN_BIN="./nnn" 64 WORK_DIR="" 65 OUT_DIR="" 66 DELAY_MS=200 67 VERBOSE=0 68 PASS_COUNT=0 69 FAIL_COUNT=0 70 71 # ---------- helpers ---------- 72 usage() { 73 sed -n '2,/^$/{ s/^#//; s/^ //; p; }' "$0" 74 exit 1 75 } 76 77 die() { 78 printf "error: %s\n" "$1" >&2 79 exit 1 80 } 81 82 cleanup() { 83 # Kill nnn/script if still running 84 if [ -n "$SCRIPT_PID" ] && kill -0 "$SCRIPT_PID" 2>/dev/null; then 85 kill "$SCRIPT_PID" 2>/dev/null || true 86 wait "$SCRIPT_PID" 2>/dev/null || true 87 fi 88 # Close the input pipe fd 89 exec 3>&- 2>/dev/null || true 90 # Remove temporary working directory 91 if [ -n "$TMPWORK" ] && [ -d "$TMPWORK" ]; then 92 rm -rf "$TMPWORK" 93 fi 94 # Remove the FIFO 95 if [ -n "$INPUT_FIFO" ] && [ -p "$INPUT_FIFO" ]; then 96 rm -f "$INPUT_FIFO" 97 fi 98 } 99 100 msleep() { 101 # Portable millisecond sleep: try fractional seconds, fall back to 1s. 102 _s=$(( $1 / 1000 )) 103 _ms=$(( $1 % 1000 )) 104 if [ "$_ms" -gt 0 ]; then 105 sleep "${_s}.$(printf '%03d' "$_ms")" 2>/dev/null || sleep $(( _s + 1 )) 106 elif [ "$_s" -gt 0 ]; then 107 sleep "$_s" 108 fi 109 } 110 111 # Map a key name to a raw byte sequence 112 map_key() { 113 case "$1" in 114 Enter) printf '\r' ;; 115 Escape) printf '\033' ;; 116 Tab) printf '\t' ;; 117 Space) printf ' ' ;; 118 BSpace) printf '\177' ;; 119 Up) printf '\033[A' ;; 120 Down) printf '\033[B' ;; 121 Right) printf '\033[C' ;; 122 Left) printf '\033[D' ;; 123 Home) printf '\033[H' ;; 124 End) printf '\033[F' ;; 125 PgUp) printf '\033[5~' ;; 126 PgDn) printf '\033[6~' ;; 127 Delete) printf '\033[3~' ;; 128 Insert) printf '\033[2~' ;; 129 F1) printf '\033OP' ;; 130 F2) printf '\033OQ' ;; 131 F3) printf '\033OR' ;; 132 F4) printf '\033OS' ;; 133 F5) printf '\033[15~' ;; 134 F6) printf '\033[17~' ;; 135 F7) printf '\033[18~' ;; 136 F8) printf '\033[19~' ;; 137 F9) printf '\033[20~' ;; 138 F10) printf '\033[21~' ;; 139 F11) printf '\033[23~' ;; 140 F12) printf '\033[24~' ;; 141 C-?) 142 # Ctrl+letter: convert to control character (1-26 for a-z) 143 _letter="$(printf '%s' "$1" | cut -c3)" 144 _upper="$(printf '%s' "$_letter" | tr 'a-z' 'A-Z')" 145 _ord=$(printf '%d' "'$_upper") 146 _ctrl=$(( _ord - 64 )) 147 printf "\\$(printf '%03o' "$_ctrl")" 148 ;; 149 ?) 150 # Single character — send as-is 151 printf '%s' "$1" 152 ;; 153 *) 154 # Multi-char: send as literal keystrokes 155 printf '%s' "$1" 156 ;; 157 esac 158 } 159 160 # Send raw bytes into the nnn pty via the FIFO 161 send_key() { 162 map_key "$1" >&3 163 } 164 165 send_literal() { 166 printf '%s' "$1" >&3 167 } 168 169 # Strip ANSI escape sequences and control chars to get plain content 170 strip_ansi() { 171 # Use printf to get a literal ESC character for portable sed 172 _esc="$(printf '\033')" 173 _cr="$(printf '\r')" 174 _bs="$(printf '\010')" 175 # 1) CSI sequences: ESC [ <params> <letter> 176 # 2) OSC sequences: ESC ] ... (until ESC or end) 177 # 3) Charset select: ESC ( <char>, ESC ) <char> 178 # 4) Other 2-byte: ESC <char> (for =, >, <, etc.) 179 # 5) Lone ESC 180 # 6) char + backspace pairs (tty echo artifacts) 181 # 7) Carriage returns 182 sed "s/${_esc}\[[^a-zA-Z]*[a-zA-Z]//g 183 s/${_esc}\][^${_esc}]*//g 184 s/${_esc}[()].\{0,1\}//g 185 s/${_esc}.//g 186 s/${_esc}//g 187 s/.${_bs}//g 188 s/${_cr}//g" 189 } 190 191 # Get a clean snapshot of the current terminal output (full history) 192 get_screen() { 193 if [ -f "$TYPESCRIPT" ]; then 194 tail -c 16384 "$TYPESCRIPT" | strip_ansi 195 fi 196 } 197 198 # Get only the terminal output produced since the last checkpoint 199 # This is what assertions run against, so they test the current screen. 200 LAST_OFFSET=0 201 get_recent_screen() { 202 if [ -f "$TYPESCRIPT" ]; then 203 _size="$(wc -c < "$TYPESCRIPT")" 204 _new=$(( _size - LAST_OFFSET )) 205 if [ "$_new" -gt 0 ]; then 206 tail -c "$_new" "$TYPESCRIPT" | strip_ansi 207 fi 208 fi 209 } 210 211 # Advance the checkpoint to the current end of the typescript 212 advance_offset() { 213 if [ -f "$TYPESCRIPT" ]; then 214 LAST_OFFSET="$(wc -c < "$TYPESCRIPT")" 215 fi 216 } 217 218 snapshot() { 219 _label="$1" 220 _file="${OUT_DIR}/${_label}.txt" 221 get_screen > "$_file" 222 [ "$VERBOSE" -eq 1 ] && printf " snapshot: %s\n" "$_file" 223 } 224 225 assert_screen() { 226 _pattern="$1" 227 _content="$(get_screen)" 228 if printf '%s' "$_content" | grep -qF "$_pattern"; then 229 PASS_COUNT=$(( PASS_COUNT + 1 )) 230 [ "$VERBOSE" -eq 1 ] && printf " PASS: assert '%s'\n" "$_pattern" 231 else 232 FAIL_COUNT=$(( FAIL_COUNT + 1 )) 233 printf " FAIL: expected '%s' on screen\n" "$_pattern" >&2 234 printf " --- screen (last 2000 chars) ---\n" >&2 235 printf '%s' "$_content" | tail -c 2000 >&2 236 printf "\n --- end ---\n" >&2 237 fi 238 } 239 240 refute_screen() { 241 _pattern="$1" 242 _content="$(get_recent_screen)" 243 if printf '%s' "$_content" | grep -qF "$_pattern"; then 244 FAIL_COUNT=$(( FAIL_COUNT + 1 )) 245 printf " FAIL: unexpected '%s' on screen\n" "$_pattern" >&2 246 printf " --- screen (last 2000 chars) ---\n" >&2 247 printf '%s' "$_content" | tail -c 2000 >&2 248 printf "\n --- end ---\n" >&2 249 else 250 PASS_COUNT=$(( PASS_COUNT + 1 )) 251 [ "$VERBOSE" -eq 1 ] && printf " PASS: refute '%s'\n" "$_pattern" 252 fi 253 } 254 255 # ---------- parse options ---------- 256 while getopts "e:d:o:t:vh" opt; do 257 case "$opt" in 258 e) NNN_BIN="$OPTARG" ;; 259 d) WORK_DIR="$OPTARG" ;; 260 o) OUT_DIR="$OPTARG" ;; 261 t) DELAY_MS="$OPTARG" ;; 262 v) VERBOSE=1 ;; 263 h) usage ;; 264 *) usage ;; 265 esac 266 done 267 shift $(( OPTIND - 1 )) 268 269 INPUT_FILE="${1:?input file required}" 270 shift 271 NNN_EXTRA_ARGS="$*" 272 273 [ -f "$INPUT_FILE" ] || die "input file not found: $INPUT_FILE" 274 command -v script >/dev/null 2>&1 || die "'script' command is required but not found" 275 276 # Resolve nnn binary 277 case "$NNN_BIN" in 278 /*) ;; # absolute 279 *) NNN_BIN="$(cd "$(dirname "$NNN_BIN")" && pwd)/$(basename "$NNN_BIN")" ;; 280 esac 281 [ -x "$NNN_BIN" ] || die "nnn binary not found or not executable: $NNN_BIN" 282 283 # ---------- set up environment ---------- 284 TMPWORK="" 285 if [ -z "$WORK_DIR" ]; then 286 TMPWORK="$(mktemp -d "${TMPDIR:-/tmp}/nnn-test-work.XXXXXX")" 287 WORK_DIR="$TMPWORK" 288 # populate with a few test entries 289 mkdir -p "$WORK_DIR/dir1" "$WORK_DIR/dir2" 290 printf 'hello\n' > "$WORK_DIR/file1.txt" 291 printf 'world\n' > "$WORK_DIR/file2.txt" 292 fi 293 294 if [ -z "$OUT_DIR" ]; then 295 OUT_DIR="/tmp/nnn-test.$$" 296 fi 297 mkdir -p "$OUT_DIR" 298 299 TYPESCRIPT="${OUT_DIR}/typescript.raw" 300 INPUT_FIFO="${OUT_DIR}/input.fifo" 301 SCRIPT_PID="" 302 303 trap cleanup EXIT INT TERM 304 305 # Create a FIFO for feeding input to nnn 306 rm -f "$INPUT_FIFO" 307 mkfifo "$INPUT_FIFO" 308 309 # ---------- start nnn inside script(1) pty ---------- 310 # The `script` command allocates a real pty so nnn's isatty() check passes. 311 # We feed keystrokes through the FIFO connected to script's stdin. 312 # Terminal output is captured to the typescript file. 313 314 # Detect script flavour (GNU coreutils vs BSD) 315 if script --version 2>&1 | grep -q 'util-linux'; then 316 # GNU/Linux: script -q -f -c <cmd> <outfile> 317 script -q -f -c "cd '${WORK_DIR}' && exec '${NNN_BIN}' ${NNN_EXTRA_ARGS}" "$TYPESCRIPT" \ 318 < "$INPUT_FIFO" > /dev/null 2>&1 & 319 else 320 # BSD / macOS: script -q -F <outfile> <cmd> (-F = flush on BSD) 321 script -q -F "$TYPESCRIPT" sh -c "cd '${WORK_DIR}' && exec '${NNN_BIN}' ${NNN_EXTRA_ARGS}" \ 322 < "$INPUT_FIFO" > /dev/null 2>&1 & 323 fi 324 SCRIPT_PID=$! 325 326 # Open the FIFO for writing on fd 3 (keeps the pipe open so nnn doesn't get EOF) 327 exec 3>"$INPUT_FIFO" 328 329 # Give nnn time to start and render its first frame 330 msleep 500 331 332 [ "$VERBOSE" -eq 1 ] && printf "nnn started (pid %s)\n" "$SCRIPT_PID" 333 334 # Take an initial snapshot 335 snapshot "00-init" 336 337 # ---------- process input file ---------- 338 STEP=1 339 while IFS= read -r line || [ -n "$line" ]; do 340 # Skip blank lines and comments 341 case "$line" in 342 ""|\#*) continue ;; 343 esac 344 345 PADSTEP="$(printf '%02d' "$STEP")" 346 347 # Handle directives 348 case "$line" in 349 @sleep\ *) 350 _val="${line#@sleep }" 351 [ "$VERBOSE" -eq 1 ] && printf "[%s] sleep %s ms\n" "$PADSTEP" "$_val" 352 msleep "$_val" 353 ;; 354 @snapshot\ *) 355 _label="${line#@snapshot }" 356 [ "$VERBOSE" -eq 1 ] && printf "[%s] snapshot '%s'\n" "$PADSTEP" "$_label" 357 snapshot "${PADSTEP}-${_label}" 358 ;; 359 @assert\ *) 360 _pat="${line#@assert }" 361 [ "$VERBOSE" -eq 1 ] && printf "[%s] assert '%s'\n" "$PADSTEP" "$_pat" 362 assert_screen "$_pat" 363 ;; 364 @refute\ *) 365 _pat="${line#@refute }" 366 [ "$VERBOSE" -eq 1 ] && printf "[%s] refute '%s'\n" "$PADSTEP" "$_pat" 367 refute_screen "$_pat" 368 ;; 369 @mark) 370 [ "$VERBOSE" -eq 1 ] && printf "[%s] mark offset\n" "$PADSTEP" 371 advance_offset 372 ;; 373 \>*) 374 # Literal text (strip leading '>') 375 _text="${line#>}" 376 [ "$VERBOSE" -eq 1 ] && printf "[%s] literal: %s\n" "$PADSTEP" "$_text" 377 send_literal "$_text" 378 msleep "$DELAY_MS" 379 ;; 380 *) 381 # Key name — translate and send 382 [ "$VERBOSE" -eq 1 ] && printf "[%s] key: %s\n" "$PADSTEP" "$line" 383 send_key "$line" 384 msleep "$DELAY_MS" 385 ;; 386 esac 387 388 # Auto-snapshot after each step 389 snapshot "${PADSTEP}-auto" 390 391 STEP=$(( STEP + 1 )) 392 done < "$INPUT_FILE" 393 394 # Final snapshot 395 snapshot "final" 396 397 # Close the input fd — this causes nnn to see EOF and exit 398 exec 3>&- 399 400 # Wait for script/nnn to finish (with a timeout) 401 _waited=0 402 while kill -0 "$SCRIPT_PID" 2>/dev/null && [ "$_waited" -lt 3 ]; do 403 msleep 500 404 _waited=$(( _waited + 1 )) 405 done 406 if kill -0 "$SCRIPT_PID" 2>/dev/null; then 407 kill "$SCRIPT_PID" 2>/dev/null || true 408 fi 409 wait "$SCRIPT_PID" 2>/dev/null || true 410 SCRIPT_PID="" 411 412 # ---------- summary ---------- 413 TOTAL=$(( PASS_COUNT + FAIL_COUNT )) 414 if [ "$TOTAL" -gt 0 ]; then 415 printf "\n=== Results: %d passed, %d failed (of %d) ===\n" \ 416 "$PASS_COUNT" "$FAIL_COUNT" "$TOTAL" 417 fi 418 419 printf "Snapshots saved to: %s\n" "$OUT_DIR" 420 printf "Raw typescript: %s\n" "$TYPESCRIPT" 421 422 if [ "$FAIL_COUNT" -gt 0 ]; then 423 exit 2 424 fi 425 426 exit 0