diff --git a/tests/HOWTO.txt b/tests/HOWTO.txt index 6066de9..55a1847 100644 --- a/tests/HOWTO.txt +++ b/tests/HOWTO.txt @@ -12,7 +12,24 @@ Notation Defaults, set once for everything below: host 127.0.0.1, port 6667 (the subject's port; also the one irssi is told to use), password pass. Files for the bonus transfer are names under files/ (files/test.txt -> test.txt). -Run every command from the repo root. + +Where to run +------------ +Every command below is written to be typed from the repo root: the ft_irc folder, +the one that contains the Makefile, srcs/ and tests/. + cd /ft_irc +"make -C tests X" means "run make X inside tests/". The same command from inside +the folder is just "make X": + from the repo root from inside the folder + make -C tests cd tests && make + make -C tests/checks bonus cd tests/checks && make bonus + make -C tests/dynamic_tester lab cd tests/dynamic_tester && make lab +Do not combine both ("cd tests" then "make -C tests"): there is no tests/tests. +Nothing needs to be built by hand first: the tests run make and make bonus +themselves. Run them in a normal terminal window (Terminal, iTerm), at least +100 columns by 30 rows; an editor's built-in terminal is fine for the checks +and the tester, but the lab, live, step, monitor and clients modes draw a +full-screen text interface and want a real terminal. Ctrl-C stops any of them. Requirements (macOS and Linux) ------------------------------ @@ -56,6 +73,8 @@ the server sources; scripts get their own fresh server on a free port. longline 10 KB without CRLF from three clients, a fourth stays responsive churn 20 members, broadcast storm, JOIN/PART churn, RST disconnects closing what the client gets before its link is closed: 464, ERROR + syscalls close/accept/send/recv return codes traced through every close + path, ircserv and ircserv_bonus (a fd closed twice, a failed accept); needs cc client parser message grammar: prefix, command, params, trailing reply numeric reply formatting state client state: nick casefolding, invites, prefix, flags @@ -74,6 +93,7 @@ the server sources; scripts get their own fresh server on a free port. Against a server you started yourself (./ircserv ): bash tests/checks/networking/session.sh [host] [port] [password] [nick] raw IRC session, already registered python3 tests/checks/networking/.py [port] [password] + bash tests/checks/networking/syscalls.sh [repo root] [password] starts its own traced servers bash tests/checks/bonus/bot.sh [host] [port] [password] [file] ./ircserv_bonus bash tests/checks/bonus/transfer.sh [host] [port] [password] [nick] [file] [botnick] ./ircserv_bonus @@ -86,6 +106,7 @@ writes artifacts/report.html. make -C tests/dynamic_tester menu; Enter runs everything make -C tests/dynamic_tester make -C tests/dynamic_tester live everything, server and client windows shown while it runs + make -C tests/dynamic_tester step same, paused after every scenario: Enter next, c continue, p pause make -C tests/dynamic_tester debug everything, every line sent and received printed make -C tests/dynamic_tester open open the report (macOS) make -C tests/dynamic_tester [PORT=] [PASSWORD=] @@ -111,7 +132,7 @@ writes artifacts/report.html. ============================================================================== Interactive ============================================================================== - make -C tests/dynamic_tester lab # client windows in one terminal, server log, walkthrough + make -C tests/dynamic_tester lab # client windows in one terminal, server log, menu bar, walkthrough make -C tests/dynamic_tester clients # irssi or nc client windows on a launched server make -C tests/dynamic_tester # same, program preselected make -C tests/dynamic_tester monitor # live dashboard; starts the server itself @@ -126,8 +147,12 @@ writes artifacts/report.html. terminal 2: make -C tests/dynamic_tester # or any suite - Keys in the monitor: Tab moves clients -> log -> menu bar; in the menu bar Left/Right pick, - Enter or Up/Down change, b back; Up/Down Left/Right scroll the focused pane; q quits. + Keys in the monitor and the lab: Tab moves between the panes and ends on the menu bar; + in the menu bar Left/Right pick, Enter or Up/Down change, b back; Up/Down Left/Right + scroll the focused pane. Monitor: q quits. + Lab: typing goes to the focused client; Enter on an empty line runs the walkthrough step + shown above the input; type your own line to try something else first; /skip /back /stop; + the menu bar sets what the windows show, their order, log rows and filter, eol; Ctrl-D leaves. ------------------------------------------------------------------------------ Details: tests/dynamic_tester/README.md diff --git a/tests/checks/Makefile b/tests/checks/Makefile index 74dcc4d..27c2eee 100644 --- a/tests/checks/Makefile +++ b/tests/checks/Makefile @@ -10,12 +10,12 @@ # commands and numerics, parser, replies (C++, no sockets) (client/) # make -C tests/checks bonus bot dialogue, real DCC transfer (bash, fresh ircserv_bonus) (bonus/) # make -C tests/checks socket one test by name: basic socket flood probe throw overlong longline -# churn closing parser reply state registration messaging operator bot transfer +# churn closing syscalls parser reply state registration messaging operator bot transfer # # By hand: bash tests/checks/run.sh (no argument = usage) SHELL := /bin/bash -NAMES := basic socket flood probe throw overlong longline churn closing parser reply state registration messaging operator bot transfer +NAMES := basic socket flood probe throw overlong longline churn closing syscalls parser reply state registration messaging operator bot transfer .PHONY: all basic networking client bonus $(NAMES) clean help @@ -23,7 +23,7 @@ all networking bonus $(NAMES): @bash "$(CURDIR)/run.sh" $@ clean: - @rm -f "$(CURDIR)"/.*.test "$(CURDIR)"/bonus/received.bin + @rm -f "$(CURDIR)"/.*.test "$(CURDIR)"/bonus/received.bin "$(CURDIR)"/networking/.syscalls.so help: @sed -n '2,15p' "$(CURDIR)/Makefile" | sed 's/^# \{0,1\}//' diff --git a/tests/checks/networking/syscalls.c b/tests/checks/networking/syscalls.c new file mode 100644 index 0000000..7e77719 --- /dev/null +++ b/tests/checks/networking/syscalls.c @@ -0,0 +1,53 @@ +/* Preloaded into the server under test: logs every close() and every failed + * accept()/send()/recv() with its errno, so a second close of the same fd + * (EBADF) or an unexpected -1 is visible. Linux: LD_PRELOAD; macOS: dyld interpose. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static void note(const char *name, int fd, long r, int e) { + if (r < 0) + fprintf(stderr, "[sys] %s(%d) -> -1 %s\n", name, fd, strerror(e)); + else if (name[0] == 'c') + fprintf(stderr, "[sys] %s(%d) -> %ld\n", name, fd, r); +} + +#ifdef __APPLE__ +#define INTERPOSE(mine, orig) \ + __attribute__((used)) static struct { const void *m; const void *o; } \ + _ip_##orig __attribute__((section("__DATA,__interpose"))) = \ + {(const void *)(unsigned long)&mine, (const void *)(unsigned long)&orig}; +int my_close(int fd) { int r = close(fd); int e = errno; note("close", fd, r, e); errno = e; return r; } +int my_accept(int fd, struct sockaddr *a, socklen_t *l) { int r = accept(fd, a, l); int e = errno; note("accept", fd, r, e); errno = e; return r; } +ssize_t my_send(int fd, const void *b, size_t n, int f) { ssize_t r = send(fd, b, n, f); int e = errno; note("send", fd, r, e); errno = e; return r; } +ssize_t my_recv(int fd, void *b, size_t n, int f) { ssize_t r = recv(fd, b, n, f); int e = errno; note("recv", fd, r, e); errno = e; return r; } +INTERPOSE(my_close, close) +INTERPOSE(my_accept, accept) +INTERPOSE(my_send, send) +INTERPOSE(my_recv, recv) +#else +int close(int fd) { + static int (*real)(int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "close"); + int r = real(fd); int e = errno; note("close", fd, r, e); errno = e; return r; +} +int accept(int fd, struct sockaddr *a, socklen_t *l) { + static int (*real)(int, struct sockaddr *, socklen_t *) = 0; + if (!real) real = dlsym(RTLD_NEXT, "accept"); + int r = real(fd, a, l); int e = errno; note("accept", fd, r, e); errno = e; return r; +} +ssize_t send(int fd, const void *b, size_t n, int f) { + static ssize_t (*real)(int, const void *, size_t, int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "send"); + ssize_t r = real(fd, b, n, f); int e = errno; note("send", fd, r, e); errno = e; return r; +} +ssize_t recv(int fd, void *b, size_t n, int f) { + static ssize_t (*real)(int, void *, size_t, int) = 0; + if (!real) real = dlsym(RTLD_NEXT, "recv"); + ssize_t r = real(fd, b, n, f); int e = errno; note("recv", fd, r, e); errno = e; return r; +} +#endif diff --git a/tests/checks/networking/syscalls.sh b/tests/checks/networking/syscalls.sh new file mode 100644 index 0000000..1670170 --- /dev/null +++ b/tests/checks/networking/syscalls.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Runs the server with close/accept/send/recv traced (syscalls.c preloaded) and +# reads the return codes afterwards: +# FAIL close() returned -1 (the same fd closed twice) or accept() failed +# ok every close() succeeded once +# Other failed calls (send/recv) are listed for information; EAGAIN is normal. +# +# ircserv: wrong password, QUIT, three over-long lines, an RST-closed +# client, a kicked client, SIGINT with clients connected +# ircserv_bonus: the same, plus a full transfer, an offer that is rejected and +# a new one after it, and SIGINT in the middle of a transfer +# +# bash tests/checks/networking/syscalls.sh [repo root] [password] +# Starts its own servers on free ports (the preload has to reach them). + +set -u +ROOT=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)} +PASS=${2:-pass} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="$HERE/.syscalls.so" +BIG="$ROOT/files/.syscalls_big.bin" +failed=0 +ok() { echo " ok $1"; } +bad() { echo " FAIL $1"; failed=1; } + +echo "[trace library]" +if [ "$(uname)" = Darwin ]; then + cc -shared -o "$LIB" "$HERE/syscalls.c" 2>/dev/null && PRE="DYLD_INSERT_LIBRARIES=$LIB" +else + cc -shared -fPIC -o "$LIB" "$HERE/syscalls.c" -ldl 2>/dev/null && PRE="LD_PRELOAD=$LIB" +fi +[ -f "$LIB" ] && ok "built ($(basename "$LIB"))" || { bad "could not build syscalls.c (needs cc)"; exit 1; } +cleanup() { [ -n "${SRV:-}" ] && kill $SRV 2>/dev/null; rm -f "${TRACE:-}" "$LIB" "$BIG"; } +trap cleanup EXIT + +trace_run() { # $1 = ircserv | ircserv_bonus + local bin=$1 bonus=0 + [ "$bin" = ircserv_bonus ] && bonus=1 + [ -x "$ROOT/$bin" ] || { echo " no $bin at $ROOT, skipped"; return; } + echo "[$bin: every close path, traced]" + TRACE=$(mktemp -t syscalls.XXXXXX) + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])') + [ $bonus = 1 ] && head -c 20000000 /dev/urandom > "$BIG" + ( cd "$ROOT" && exec env $PRE "./$bin" "$PORT" "$PASS" 2>"$TRACE" >/dev/null ) & + SRV=$! + for i in $(seq 1 40); do + python3 -c "import socket; socket.create_connection(('127.0.0.1', $PORT), timeout=0.5).close()" 2>/dev/null && break + sleep 0.1 + done + python3 - "$PORT" "$PASS" "$SRV" "$bonus" <<'PYEOF' +import socket, time, re, os, signal, sys, struct +PORT, PW, SRV, BONUS = int(sys.argv[1]), sys.argv[2], int(sys.argv[3]), sys.argv[4] == "1" +def reg(n): + s = socket.create_connection(("127.0.0.1", PORT)); s.settimeout(1) + s.sendall(("PASS %s\r\nNICK %s\r\nUSER %s 0 * :x\r\n" % (PW, n, n)).encode()); time.sleep(0.3) + try: s.recv(4096) + except socket.timeout: pass + return s +def offer(a, b, fname): + a.sendall(("PRIVMSG DCCBot :!send bob %s\r\n" % fname).encode()); time.sleep(0.5) + d = b.recv(4096).decode(errors="replace") + m = re.search(r"DCC SEND \S+ (\S+) (\d+) (\d+)", d) + return (int(m.group(2)), int(m.group(3))) if m else (None, None) +def pull(port): + ds = socket.create_connection(("127.0.0.1", port)); ds.settimeout(3); got = 0 + try: + while True: + c = ds.recv(65536) + if not c: break + got += len(c) + except socket.timeout: pass + ds.close(); return got +a = reg("alice"); b = reg("bob"); a.sendall(b"JOIN #x\r\n"); b.sendall(b"JOIN #x\r\n"); time.sleep(0.3) +w = socket.create_connection(("127.0.0.1", PORT)); w.sendall(b"PASS nope\r\nNICK w\r\nUSER w 0 * :w\r\n"); time.sleep(0.4); w.close() +q = reg("quitter"); q.sendall(b"QUIT :bye\r\n"); time.sleep(0.4); q.close() +o = reg("longy") +for _ in range(3): o.sendall(b"X" * 600 + b"\r\n"); time.sleep(0.2) +time.sleep(0.4); o.close() +k = reg("killed"); k.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)); k.close(); time.sleep(0.4) +a.sendall(b"KICK #x bob :out\r\n"); time.sleep(0.3) +print(" ok wrong password, QUIT, over-long close, RST close, KICK") +if BONUS: + b.sendall(b"JOIN #x\r\n"); time.sleep(0.2) + port, size = offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.3) + got = pull(port); time.sleep(0.4) + print((" ok full transfer: %d of %d bytes" if got == size else " FAIL full transfer: %d of %d bytes") % (got, size)) + offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!reject\r\n"); time.sleep(0.5) + port, size = offer(a, b, "test.txt"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.4) + pull(port); time.sleep(0.4) + print(" ok offer rejected, new offer accepted and transferred") + port, size = offer(a, b, ".syscalls_big.bin"); b.sendall(b"PRIVMSG DCCBot :!accept\r\n"); time.sleep(0.4) + ds = socket.create_connection(("127.0.0.1", port)); time.sleep(0.6) # connected, never reads + os.kill(SRV, signal.SIGINT); time.sleep(1.0) + print(" ok SIGINT with a transfer in progress") +else: + os.kill(SRV, signal.SIGINT); time.sleep(0.8) + print(" ok SIGINT with clients connected") +PYEOF + kill -INT $SRV 2>/dev/null; sleep 0.5; kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; SRV="" + local closes dbl acc + closes=$(grep -c "^\[sys\] close(" "$TRACE") + dbl=$(grep "^\[sys\] close(" "$TRACE" | grep -c -- "-> -1") + acc=$(grep "^\[sys\] accept(" "$TRACE" | grep -- "-> -1" | grep -vc "again\|Resource temporarily") + if [ "$dbl" -eq 0 ]; then ok "every close() succeeded once ($closes calls)"; else + bad "close() returned -1 $dbl time(s): the same fd closed twice" + grep "^\[sys\] close(" "$TRACE" | grep -B1 -- "-> -1" | sed 's/^/ /' + fi + [ "$acc" -eq 0 ] && ok "no failed accept()" || bad "accept() failed $acc time(s)" + grep "^\[sys\] \(send\|recv\)(" "$TRACE" | grep -- "-> -1" | sed 's/^\[sys\] //' | sort | uniq -c | sed 's/^ *\([0-9]*\) \(.*\)$/ \2 x\1 (info)/' + rm -f "$TRACE" "$BIG" +} +trace_run ircserv +trace_run ircserv_bonus +exit $failed diff --git a/tests/checks/run.sh b/tests/checks/run.sh index 5af7114..8a9bed8 100755 --- a/tests/checks/run.sh +++ b/tests/checks/run.sh @@ -34,6 +34,7 @@ spec() { longline) echo "networking/longline.py|ircserv|10 KB without CRLF from three clients while a fourth stays responsive" ;; churn) echo "networking/churn.py|ircserv|20 members: broadcast storm, JOIN/PART churn, RST disconnects, a stalled member" ;; closing) echo "networking/closing.py|ircserv|what the client gets before the server closes its link: 464, ERROR" ;; + syscalls) echo "networking/syscalls.sh|SELF|close/accept/send/recv return codes traced through every close path, both binaries (a fd closed twice, a failed accept)" ;; parser) echo "client/parser.cpp||message grammar: prefix, command, params, trailing" ;; reply) echo "client/reply.cpp||numeric reply formatting" ;; state) echo "client/state.cpp|SRV|client state: nick casefolding, invites, identity, prefix, flags" ;; @@ -46,7 +47,7 @@ spec() { esac } G_BASIC="basic" -G_NET="socket flood probe throw overlong longline churn closing" +G_NET="socket flood probe throw overlong longline churn closing syscalls" G_CLIENT="parser reply state registration messaging operator" G_BONUS="bot transfer" ALL="$G_BASIC $G_NET $G_CLIENT $G_BONUS" @@ -105,6 +106,13 @@ for name in "${names[@]}"; do IFS='|' read -r file kind desc <<< "$line" file="$HERE/$file"; dir="$(dirname "$file")" echo; echo "${B}${C}[ $name ]${N} ${D}${desc}${N}" + if [ "$kind" = SELF ]; then # the script starts (and traces) its own servers + build_server ircserv || { echo " ${R}[FAIL]${N} make failed"; failed+=("$name"); continue; } + build_server ircserv_bonus || true + bash "$file" "$ROOT" "$PASSWORD" 2>&1 | render; rc=${PIPESTATUS[0]} + [ "$rc" -eq 0 ] && passed+=("$name") || failed+=("$name") + continue + fi case "$file" in *.cpp) objs=() diff --git a/tests/dynamic_tester/Makefile b/tests/dynamic_tester/Makefile index ccbabed..8fce675 100644 --- a/tests/dynamic_tester/Makefile +++ b/tests/dynamic_tester/Makefile @@ -2,6 +2,7 @@ # # make everything -> artifacts/report.html # make live everything, watching server + client windows live +# make step same, paused after every scenario (Enter = next, c = continue) # make mandatory | robustness | load | bonus | compare # make live-load the slow-reader load test with the live view # make lab interactive: client windows + server log + help + /walk @@ -31,7 +32,7 @@ SETS = $(foreach kv,$(SET),--set $(kv)) COMMON = --binary $(BINARY) --bonus-binary $(BONUS_BINARY) --host $(HOST) --port $(PORT) \ --password $(PASSWORD) --outdir $(OUTDIR) --report $(REPORT) --no-intro $(SETS) -.PHONY: all mandatory robustness load bonus compare terminal external debug live live-load \ +.PHONY: all mandatory robustness load bonus compare terminal external debug live live-load step \ lab irssi nc clients monitor monitor-bonus attach config open clean help all: @@ -57,6 +58,8 @@ live: @$(PYTHON) test_irc.py --mode all --live $(COMMON) live-load: @$(PYTHON) test_irc.py --mode load --live $(COMMON) +step: + @$(PYTHON) test_irc.py --mode all --live --step $(COMMON) N ?= 3 lab: diff --git a/tests/dynamic_tester/README.md b/tests/dynamic_tester/README.md index 64fb0aa..5ca602b 100644 --- a/tests/dynamic_tester/README.md +++ b/tests/dynamic_tester/README.md @@ -16,6 +16,7 @@ make -C tests/dynamic_tester load 30 clients reading one byte at a tim make -C tests/dynamic_tester bonus bot commands, file offer, the transfer, DCC relayed between clients make -C tests/dynamic_tester compare the same bot dialogue against ircserv_bonus and ircserv, saved to two files make -C tests/dynamic_tester live everything, with the server and client windows on screen while it runs +make -C tests/dynamic_tester step same, paused after every scenario: Enter runs the next one, c continues, p pauses again make -C tests/dynamic_tester debug everything, every line sent and received printed make -C tests/dynamic_tester open open the report (macOS) make -C tests/dynamic_tester clean remove artifacts/ @@ -65,24 +66,36 @@ and edit to match the server. A wrong entry appears as NOTE or FAIL in the affec ## Lab `make -C tests/dynamic_tester lab` asks how many clients and their nicks, registers them and -shows each as a pane, the help on the left, the server log at the bottom and one input line. +shows each as a pane, the help on the left, the server log at the bottom, a menu bar under the +title and one input line. Whatever you type goes to the focused client as an IRC line. | keys | | |---|---| -| Tab / Shift-Tab | next / previous pane; typed lines go to the focused client | +| Tab / Shift-Tab | next / previous stop: client panes, server log, help, menu bar (hidden panes are skipped) | | Up/Down, Left/Right, PgUp/PgDn | scroll the focused pane | -| Ctrl-L | layout: help+log / windows only / help only | -| Enter | send the input line (empty Enter: next walkthrough step) | +| Enter | send the input line to the focused client; with an empty line, run the walkthrough step shown | +| Ctrl-L | layout: help + log / windows only / help only | +| Ctrl-D | leave | + +Menu bar (Tab to it, then Left/Right pick, Enter or Up/Down change, b back): what the client +windows show (all / sent / received / notes), their order (number / nick / unread), windows per +row, server-log rows, what the log shows (all / in / out / errors), help on or off, line ending, +start or step the walkthrough, add a client, quit. + +Walkthrough (`/walk` or the menu): the next step is shown on the line above the input, with the +client that will send it and what to expect. Enter runs it. Typing your own line sends that +first and leaves the step pending, so you can try an alternative before or instead of it. +`/skip` skips the step, `/back` goes back one, `/stop` ends it. The steps cover JOIN, messages, +TOPIC, +t, +o, KICK, +i and INVITE, +k, +l, the query, the bot and a transfer, QUIT. | command | | |---|---| -| `/new 2`, `/new dave,eve` | more clients | -| `/use N` | make client N active | +| `/new 2`, `/new dave,eve` | more clients (five at most) | +| `/use N` | make client N the one you type to | | `/msg `, `/join #chan` | PRIVMSG / JOIN | | `/eol crlf\|lf\|none`, `/long 510`, `/noeol `, `/raw ` | line endings, exact lengths, partial sends | | `/pause`, `/resume` | the client stops and restarts reading | -| `/close` | close the active client abruptly | -| `/walk` | guided walkthrough, one step per Enter: join, topic, +t, +o, kick, +i and invite, +k, +l, bot, transfer | +| `/close` | close the focused client abruptly | | `/send bob ` then `/dcc` in bob's window | file transfer without the bot | | `PRIVMSG DCCBot :!send bob `, `PRIVMSG DCCBot :!accept`, then `/dcc` | file transfer with the bot | | `/help`, `/cols N`, `/log N`, `/quit` | hide help, windows per row, log rows, leave | diff --git a/tests/dynamic_tester/test_irc.py b/tests/dynamic_tester/test_irc.py index 6cc1eb6..7489e62 100644 --- a/tests/dynamic_tester/test_irc.py +++ b/tests/dynamic_tester/test_irc.py @@ -288,6 +288,9 @@ def __init__(self, args): self.t_start = time.time() self.lock = threading.Lock() self.planned = [] # (key, title, group) for the live scenario list + self.step = False # live view: pause after every scenario until told to go on + self.waiting = False + self.go = threading.Event() def begin(self, key, title, group, blurb): sc = Scenario(key, title, group, blurb) @@ -310,6 +313,9 @@ def end(self): sc.state = "done" with self.lock: self.current = None + if self.step: # live view step mode: hold here until the viewer says next / continue + self.go.clear(); self.waiting = True + self.go.wait(); self.waiting = False def record(self, name, status, what="", why="", expect="", got="", fix="", dur_ms=0.0): ch = Check(name, status, what, why, expect, got, fix, dur_ms) @@ -2534,7 +2540,7 @@ def rows_of(lines, pane, ih, iw): hdr = " ft_irc tester mode=%s port %s pid %s RSS %d MB CPU %.1f%% %d pass %d fail %d note %d skip%s" % ( self.mode, REPORT.args.port, (srv.proc.pid if (srv and srv.proc) else "-"), mon.rss_mb if mon else 0, mon.cpu if mon else 0.0, t[PASS], t[FAIL], t[NOTE], t[SKIP], - " DONE - press q" if self.done else "") + " DONE - press q" if self.done else (" PAUSED - Enter: next scenario, c: continue" if REPORT.waiting else (" step mode: pausing after this scenario" if REPORT.step else ""))) put(0, 0, hdr.ljust(w - 1), curses.A_REVERSE | BOLD) listeners = max(0, self.socks - len(clients)) if self.socks >= 0 else -1 stalled = sum(1 for c in clients if c.sendq >= 32768) @@ -2625,7 +2631,7 @@ def rows_of(lines, pane, ih, iw): log_lines.append((attr, printable("%s %s" % (time.strftime("%H:%M:%S", time.localtime(tt)), re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S+\s+", "", txt))))) for i, (attr, line) in enumerate(rows_of(log_lines, npanes, ih, iw)): put(iy + i, ix, line, attr) - put(h - 1, 0, (" q quit (after DONE) Tab / Shift-Tab pick a pane Up/Down Left/Right PgUp/PgDn scroll it red frame = stalled client %s" + put(h - 1, 0, (" p pause after each scenario Enter/space next c continue q quit (after DONE) Tab pick a pane arrows/PgUp/PgDn scroll red frame = stalled %s" % (self.error or "")).ljust(w - 1), curses.A_REVERSE | (C_R if self.error else 0)) stdscr.refresh() try: @@ -2634,6 +2640,14 @@ def rows_of(lines, pane, ih, iw): k = -1 if k in (ord("q"), ord("Q")) and self.done: break + elif k in (ord("p"), ord("P")): # pause after every scenario from now on + REPORT.step = True + elif k in (10, 13, curses.KEY_ENTER, ord(" ")): # next scenario (stays in step mode) + if not REPORT.step: + REPORT.step = True + REPORT.go.set() + elif k in (ord("c"), ord("C")): # continue without pausing + REPORT.step = False; REPORT.go.set() elif k == 9: focus = (focus + 1) % (npanes + 1) elif k == curses.KEY_BTAB: @@ -2651,11 +2665,12 @@ def rows_of(lines, pane, ih, iw): LAB_HELP = [ ("Keys", None), - ("Tab / Shift-Tab", "next / previous pane; a client pane is also where typed lines go"), + ("Tab / Shift-Tab", "next / previous pane: clients, server log, help, menu bar; a client pane is where typed lines go"), + ("menu bar", "Left/Right pick an option, Enter or Up/Down change it, b back: what windows show, order, per row, log rows, log filter, help, eol, walkthrough, new client, quit"), ("Up/Down Left/Right", "scroll the focused pane (vertical / horizontal)"), ("PgUp / PgDn", "scroll the focused pane by a page"), ("Ctrl-L", "layout: help+log / windows only / help only"), - ("Enter", "send the input line to the active client (empty Enter = next walkthrough step)"), + ("Enter", "send the input line to the active client (empty Enter = run the walkthrough step shown)"), ("", None), ("Lab commands", None), ("/new [nick|N|a,b]", "1 client, N clients, or these nicks (auto PASS/NICK/USER)"), @@ -2667,7 +2682,7 @@ def rows_of(lines, pane, ih, iw): ("/raw ", "send exactly this (+ current eol)"), ("/pause | /resume", "stop / restart reading (the ^Z test of the eval sheet)"), ("/close", "close the active client abruptly (like kill -9 on nc)"), - ("/walk", "guided walkthrough, one step per Enter (all commands + bot + transfer)"), + ("/walk", "walkthrough: the next step is shown first, Enter runs it; type your own line to try something else; /skip /back /stop"), ("/help /cols N /log N /helpw N", "hide help / windows per row / log rows / help width"), ("/quit", "leave the lab (or Ctrl-D)"), ("", None), @@ -2947,6 +2962,12 @@ def handle(self, text): c.closed = True; c.note("closed abruptly (no QUIT)") elif cmd in ("/walk", "/demo"): self.start_demo() + elif cmd == "/skip" and self.demo is not None: + self.demo_i = min(len(self.demo), self.demo_i + 1); self.msg = "skipped" + elif cmd == "/back" and self.demo is not None: + self.demo_i = max(0, self.demo_i - 1); self.msg = "back one step" + elif cmd == "/stop" and self.demo is not None: + self.demo = None; self.msg = "walkthrough stopped" elif cmd == "/irssi" and len(parts) > 1 and parts[1].isdigit(): k = max(1, min(5, int(parts[1]))) self.msg = open_client_windows("irssi", list(NICKS[:k]), self.args.host, self.args.port, self.args.password, "windows") @@ -2965,7 +2986,7 @@ def handle(self, text): self.msg = "no client: /new first"; return self.send_line(c, text) - # ---- demo ---- + # ---- walkthrough: the next step is shown first; Enter runs it ---- def start_demo(self): want = ["alice", "bob", "carol"] have = dict((cl.nick, cl) for cl in self.clients) @@ -2973,21 +2994,30 @@ def start_demo(self): if n not in have: self.new_client(n) self.demo = [(w, l.replace("DCCBOT", self.bot), n) for (w, l, n) in DEMO_STEPS]; self.demo_i = 0 - self.msg = "walkthrough ready: press Enter to run each step (%d steps)" % len(self.demo) + self.msg = "walkthrough: the next step is shown above this line. Enter runs it; type a line to send something else first; /skip /back /stop" + + def walk_bar(self): + """One line describing the pending step, or '' when no walkthrough is running.""" + if self.demo is None: + return "" + if self.demo_i >= len(self.demo): + return "WALK done (%d steps). /stop to close, /back to repeat the last one" % len(self.demo) + who, line, what = self.demo[self.demo_i] + return "WALK %d/%d next: %s > %s %s" % (self.demo_i + 1, len(self.demo), who, line, what) def demo_step(self): if self.demo_i >= len(self.demo): - self.msg = "walkthrough finished"; self.demo = None; return + self.msg = "walkthrough finished (/stop to close it)"; return who, line, what = self.demo[self.demo_i]; self.demo_i += 1 c = next((cl for cl in self.clients if cl.nick == who), None) if c is None: - self.msg = "demo: no client %s" % who; return - self.active = c.idx + self.msg = "walkthrough: no client %s" % who; return + self.active = c.idx; self.focus_request = c.idx if line == "@dcc": time.sleep(0.3); self.pump(); self.dcc_receive(c) else: self.send_line(c, line) - self.msg = "step %d/%d %s: %s -- %s" % (self.demo_i, len(self.demo), who, line, what) + self.msg = "ran %d/%d %s > %s" % (self.demo_i, len(self.demo), who, line) # ---- UI ---- def run(self, stdscr): @@ -3011,10 +3041,52 @@ def run(self, stdscr): self.new_client(n) self.active = 0 self.msg = ("%d clients registered (%s). Tab moves between panes and picks the client you type to; " - "Up/Down and Left/Right scroll the focused pane; /walk for the walkthrough" + "the last Tab stop is the menu bar; /walk for the walkthrough" % (len(self.clients), ", ".join(c.nick for c in self.clients))) - focus = 0 # 0..n-1 client panes, n = server log, n+1 = help + # focus: 0..n-1 client panes, LOG, HELP, MENU (the last three are constants below n is added) + LOG, HELP, MENU = "log", "help", "menu" + focus = 0 self.hscroll_log = 0; self.help_h = 0 + # ---- view options, edited from the menu bar (same keys as the monitor) ---- + SHOWS = ("all", "sent", "received", "notes") + SORTS = ("number", "nick", "unread") + LOGFS = ("all", "in", "out", "errors") + opts = {"show": 0, "sort": 0, "logf": 0} + MENU_ITEMS = [ # (key, label, kind) + ("show", "windows show", "cycle"), ("sort", "order", "cycle"), ("cols", "per row", "int"), + ("log", "log rows", "int"), ("logf", "log shows", "cycle"), ("help", "help", "toggle"), + ("eol", "eol", "cycle"), ("walk", "walkthrough", "action"), ("new", "new client", "action"), + ("quit", "quit", "action"), + ] + menu_i = 0 + + def menu_value(key): + if key == "show": + return SHOWS[opts["show"]] + if key == "sort": + return SORTS[opts["sort"]] + if key == "cols": + return str(self.grid_cols) if self.grid_cols else "auto" + if key == "log": + return str(self.log_rows) if self.log_rows else "off" + if key == "logf": + return LOGFS[opts["logf"]] + if key == "help": + return "on" if self.help_on else "off" + if key == "eol": + return self.eol + if key == "walk": + return "next" if self.demo is not None else "start" + return "" + + def visible_panes(): + v = list(range(len(self.clients))) + if self.log_rows: + v.append(LOG) + if self.help_on: + v.append(HELP) + v.append(MENU) + return v def put(y, x, s, attr=0): h, w = stdscr.getmaxyx() @@ -3053,21 +3125,37 @@ def box(y, x, hh, ww, title, attr, focused): fr = getattr(self, "focus_request", None) if fr is not None: focus = fr; self.focus_request = None - focus = min(focus, n + 1) - if focus < n: + vis = visible_panes() + if focus not in vis: + focus = vis[0] + if isinstance(focus, int): self.active = focus act = self.clients[self.active] if self.clients else None - put(0, 0, (" LAB server 127.0.0.1:%s pid %s RSS %d MB clients %d eol=%s typing goes to: %s focus: %s" % ( - self.args.port, srv.proc.pid if (srv and srv.proc) else "-", rss, n, self.eol, - act.nick if act else "-", (self.clients[focus].nick if focus < n else ("server log" if focus == n else "help")))).ljust(w - 1), - curses.A_REVERSE | BOLD) - + fname = {LOG: "server log", HELP: "help", MENU: "menu"}.get(focus, self.clients[focus].nick if isinstance(focus, int) and focus < n else "-") + put(0, 0, (" LAB server 127.0.0.1:%s pid %s RSS %d MB clients %d typing goes to: %s focus: %s" % ( + self.args.port, srv.proc.pid if (srv and srv.proc) else "-", rss, n, + act.nick if act else "-", fname)).ljust(w - 1), curses.A_REVERSE | BOLD) + # ---- menu bar (row 1) ---- + x = 0 + put(1, 0, " " * (w - 1), C_DIM) + for i, (key, label, kind) in enumerate(MENU_ITEMS): + val = menu_value(key) + txt = " %s%s " % (label, (": " + val) if val else "") + sel = (focus == MENU and i == menu_i) + put(1, x, txt, (curses.A_REVERSE | BOLD) if sel else (C_FOCUS if focus == MENU else C_DIM)) + x += len(txt) + 1 + tail = (" %s/%s pick Enter or Up/Down change b back" % (G["inn"], G["out"])) if focus == MENU else " Tab: panes, log, help, menu" + put(1, min(x, max(0, w - len(tail) - 1)), tail, C_SENT if focus == MENU else C_DIM) + + top = 2 + walk = self.walk_bar() + bottom = 2 + (1 if walk else 0) # msg + input (+ walk bar) # ---- help pane (left) ---- - hw = self.help_w if self.help_on else 0; top = 1 + hw = self.help_w if self.help_on else 0 log_rows = self.log_rows if self.help_on: - hh_ = h - top - 2 - (log_rows + 2 if log_rows else 0) - iy, ix, ih, iw = box(top, 0, hh_, hw, "HELP", C_FRAME, focus == n + 1) + hh_ = h - top - bottom - (log_rows + 2 if log_rows else 0) + iy, ix, ih, iw = box(top, 0, hh_, hw, "HELP", C_FRAME, focus == HELP) self.help_scroll = max(0, min(self.help_scroll, max(0, len(LAB_HELP) - ih))) for i, (k, d) in enumerate(LAB_HELP[self.help_scroll: self.help_scroll + ih]): if d is None: @@ -3076,25 +3164,31 @@ def box(y, x, hh, ww, title, attr, focused): line = ("%-22s %s" % (k, d))[self.help_h:] put(iy + i, ix, line[:iw], C_DIM) put(iy + i, ix, line[: min(iw, max(0, 22 - self.help_h))], C_RECV) - # ---- client panes (grid, right) ---- + # ---- client panes (grid, right), in the chosen order ---- gx = hw; gw = w - gx; nn = max(1, n) cols = self.grid_cols or (3 if gw >= 105 else (2 if gw >= 70 else 1)) cols = max(1, min(nn, cols)); rws = (nn + cols - 1) // cols - area_h = h - top - 2 - (log_rows + 2 if log_rows else 0); win_h = max(4, area_h // rws); win_w = gw // cols - for c in self.clients: - r, col = divmod(c.idx, cols) + area_h = h - top - bottom - (log_rows + 2 if log_rows else 0); win_h = max(4, area_h // rws); win_w = gw // cols + order = list(self.clients) + if SORTS[opts["sort"]] == "nick": + order.sort(key=lambda cl: cl.nick.lower()) + elif SORTS[opts["sort"]] == "unread": + order.sort(key=lambda cl: -cl.sendq) + show = SHOWS[opts["show"]] + for pos, c in enumerate(order): + r, col = divmod(pos, cols) x = gx + col * win_w; y = top + r * win_h - if col == cols - 1: - ww = w - x - else: - ww = win_w + ww = (w - x) if col == cols - 1 else win_w title = "%d %s sent %dB unread-by-it %s%s%s%s" % ( c.idx + 1, c.nick, c.last_len, human_bytes(c.sendq), " PAUSED" if c.paused else "", " CLOSED" if c.closed else "", " <- typing here" if c.idx == self.active else "") frame = C_R if (c.sendq >= 32768 or c.closed) else GRAD[c.idx % len(GRAD)] iy, ix, ih, iw = box(y, x, win_h, ww, title, frame, focus == c.idx) - rows = [(d, ("%s %s" % ({">>": G["out"], "<<": G["inn"]}.get(d, G["dot"]), t))) for (d, t) in c.lines] + lines = [(d, t) for (d, t) in c.lines + if show == "all" or (show == "sent" and d == ">>") or (show == "received" and d == "<<") + or (show == "notes" and d not in (">>", "<<"))] + rows = [(d, ("%s %s" % ({">>": G["out"], "<<": G["inn"]}.get(d, G["dot"]), t))) for (d, t) in lines] c.scroll = max(0, min(c.scroll, max(0, len(rows) - ih))) shown = rows[max(0, len(rows) - c.scroll - ih): max(0, len(rows) - c.scroll)] for i, (d, text) in enumerate(shown): @@ -3104,11 +3198,16 @@ def box(y, x, hh, ww, title, attr, focused): put(y + win_h - 1, x + ww - 14, " %s%d more " % (G["up"], c.scroll), C_DIM) # ---- server log (bottom) ---- if log_rows: - ly = h - log_rows - 4 - iy, ix, ih, iw = box(ly, 0, log_rows + 2, w, "SERVER LOG (Tab here, then Up/Down Left/Right)", C_FRAME, focus == n) + ly = h - log_rows - 2 - bottom + lf = LOGFS[opts["logf"]] + iy, ix, ih, iw = box(ly, 0, log_rows + 2, w, "SERVER LOG%s" % ("" if lf == "all" else " (%s only)" % lf), C_FRAME, focus == LOG) if srv: with srv.lock: entries = list(srv.buffer) + if lf != "all": + entries = [(tt, txt) for (tt, txt) in entries + if (lf == "in" and "from fd" in txt.lower()) or (lf == "out" and "sendto fd" in txt.lower()) + or (lf == "errors" and ("error" in txt.lower() or "closing" in txt.lower()))] self.log_scroll = max(0, min(self.log_scroll, max(0, len(entries) - ih))) end = len(entries) - self.log_scroll for i, (tt, txt) in enumerate(entries[max(0, end - ih): max(0, end)]): @@ -3116,7 +3215,9 @@ def box(y, x, hh, ww, title, attr, focused): attr = C_RECV if "from fd" in low else (C_SENT if "sendto fd" in low else (C_R if ("error" in low or "closing" in low) else C_DIM)) line = printable("%s %s" % (time.strftime("%H:%M:%S", time.localtime(tt)), re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S+\s+", "", txt))) put(iy + i, ix, line[self.hscroll_log: self.hscroll_log + iw].ljust(iw), attr) - # ---- message + input ---- + # ---- walk bar + message + input ---- + if walk: + put(h - 3, 0, walk[: w - 1].ljust(w - 1), C_FOCUS | BOLD) put(h - 2, 0, (self.msg or "")[: w - 1].ljust(w - 1), C_NOTE | BOLD) prompt = "[%s]> " % (act.nick if act else "-") counter = " len=%d (+%d eol) " % (len(self.input), len(self.eol_bytes())) @@ -3147,22 +3248,54 @@ def box(y, x, hh, ww, title, attr, focused): stdscr.timeout(120) k = {"[D": curses.KEY_LEFT, "OD": curses.KEY_LEFT, "[C": curses.KEY_RIGHT, "OC": curses.KEY_RIGHT, "[A": curses.KEY_UP, "OA": curses.KEY_UP, "[B": curses.KEY_DOWN, "OB": curses.KEY_DOWN, - "[Z": curses.KEY_BTAB, "[5~": curses.KEY_PPAGE, "[6~": curses.KEY_NPAGE}.get(seq, -1) + "[Z": curses.KEY_BTAB, "[5~": curses.KEY_PPAGE, "[6~": curses.KEY_NPAGE}.get(seq, 27 if not seq else -1) if k == -1: continue if k == 4: # Ctrl-D self.quit = True; continue + vis = visible_panes() + if k == 9: # Tab: next visible pane (clients, log, help, menu) + focus = vis[(vis.index(focus) + 1) % len(vis)]; continue + if k == curses.KEY_BTAB: # Shift-Tab: previous + focus = vis[(vis.index(focus) - 1) % len(vis)]; continue + if focus == MENU: # menu bar: Left/Right pick, Enter/Up/Down change, b/Esc back + key, label, kind = MENU_ITEMS[menu_i] + if k == curses.KEY_LEFT: + menu_i = (menu_i - 1) % len(MENU_ITEMS) + elif k == curses.KEY_RIGHT: + menu_i = (menu_i + 1) % len(MENU_ITEMS) + elif k in (ord("b"), ord("B"), 27): + focus = vis[0] + elif k in (10, 13, curses.KEY_ENTER, curses.KEY_UP, curses.KEY_DOWN, ord(" ")): + step = -1 if k == curses.KEY_DOWN else 1 + if key in ("show", "sort", "logf"): + size = {"show": len(SHOWS), "sort": len(SORTS), "logf": len(LOGFS)}[key] + opts[key] = (opts[key] + step) % size + elif key == "cols": + self.grid_cols = (self.grid_cols + step) % 6 + elif key == "log": + self.log_rows = max(0, min(20, self.log_rows + 4 * step)) + elif key == "help": + self.help_on = not self.help_on + elif key == "eol": + e = ("crlf", "lf", "none"); self.eol = e[(e.index(self.eol) + step) % 3] + elif key == "walk": + if self.demo is None: + self.start_demo() + else: + self.demo_step() + self.focus_request = None; focus = MENU + elif key == "new": + used = set(cl.nick for cl in self.clients) + self.new_client(next((nk for nk in NICKS if nk not in used), None)); focus = MENU; self.focus_request = None + elif key == "quit": + self.quit = True + continue if k in (10, 13, curses.KEY_ENTER): text = self.input; self.input = ""; self.msg = "" self.handle(text) - if focus >= n and self.clients: # typing while the log/help is focused: keep going - pass elif k in (curses.KEY_BACKSPACE, 127, 8): self.input = self.input[:-1] - elif k == 9: # Tab: next pane - focus = (focus + 1) % (n + 2) - elif k == curses.KEY_BTAB: # Shift-Tab: previous pane - focus = (focus - 1) % (n + 2) elif k == 12: # Ctrl-L: cycle layouts if self.help_on and self.log_rows: self.help_on = False; self.log_rows = 0; self.msg = "layout: client windows only (Ctrl-L again to cycle)" @@ -3173,10 +3306,10 @@ def box(y, x, hh, ww, title, attr, focused): elif k in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_PPAGE, curses.KEY_NPAGE, curses.KEY_LEFT, curses.KEY_RIGHT): step = {curses.KEY_UP: 1, curses.KEY_DOWN: -1, curses.KEY_PPAGE: 10, curses.KEY_NPAGE: -10}.get(k, 0) hstep = {curses.KEY_LEFT: -8, curses.KEY_RIGHT: 8}.get(k, 0) - if focus < n: + if isinstance(focus, int): c = self.clients[focus] c.scroll = max(0, c.scroll + step); c.hscroll = max(0, c.hscroll + hstep) - elif focus == n: + elif focus == LOG: self.log_scroll = max(0, self.log_scroll + step); self.hscroll_log = max(0, self.hscroll_log + hstep) else: self.help_scroll = max(0, self.help_scroll - step); self.help_h = max(0, self.help_h + hstep) @@ -3377,6 +3510,7 @@ def parse_args(): p.add_argument("--bonus-binary", dest="bonus_binary", default=os.getenv("IRC_TEST_BONUS_BINARY", "../../ircserv_bonus")) p.add_argument("--mode", choices=("menu", "mandatory", "robustness", "load", "bonus", "all", "compare", "lab", "irssi", "clients", "nc", "terminal"), default="menu") p.add_argument("--live", action="store_true", help="watch the server window and client windows while the suite runs") + p.add_argument("--step", action="store_true", help="with --live: pause after every scenario (Enter = next, c = continue)") p.add_argument("--no-start", action="store_true", help="use an already-running server on --host/--port") p.add_argument("--no-intro", action="store_true"); p.add_argument("--fast", action="store_true") p.add_argument("--debug", action="store_true", help="print every line sent/received live") @@ -3445,6 +3579,7 @@ def run_mode(mode, args): if args.live and HAVE_CURSES and sys.stdout.isatty(): sys.stdout = io.StringIO() ui = LiveUI(mode, extra_files) + REPORT.step = bool(args.step) try: curses.wrapper(ui.run) finally: