aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 09:34:32 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 09:34:32 +0200
commit26d800749f5d4bb150c86215b0df4444f01ed2f6 (patch)
tree4d1523078ae7c16deca97d8a636d738e1a1ae87e
parenta3ad2857f7b47202cd7ff74f965d09d691b06317 (diff)
downloadbokf-26d800749f5d4bb150c86215b0df4444f01ed2f6.tar.gz
bokf-26d800749f5d4bb150c86215b0df4444f01ed2f6.zip
tests: pty golden harness for the TUI
-rwxr-xr-xscripts/tui-golden.py680
1 files changed, 680 insertions, 0 deletions
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
new file mode 100755
index 0000000..2a60d7f
--- /dev/null
+++ b/scripts/tui-golden.py
@@ -0,0 +1,680 @@
+#!/usr/bin/env python3
+# Automated pty golden tests for bokftui.
+#
+# python3 scripts/tui-golden.py [--keep] [--verbose] [--only NAME]
+#
+# Starts a throwaway bokfd in a mktemp -d under /tmp, creates a test org, then
+# drives bokftui through scripts/tui-sandbox.sh (isolated XDG config/cache) on
+# a pty and asserts Swedish screen text. Prints PASS/FAIL per scenario and a
+# screen diff on failure; exits non-zero if anything failed.
+#
+# No third-party modules are needed. The built-in renderer below replays the
+# raw pty bytes through a small ANSI screen emulator that handles the
+# sequences a full-screen curses app emits: CSI H/f, A/B/C/D/E/F/G/d, J, K,
+# X, P, @, L/M, S/T, r (scrolling region), ignored SGR/private modes, DEC
+# special graphics (ESC(0, SO/SI), CR/LF/BS/TAB and ESC 7/8/D/M/E.
+# Limitations: no wide-character cell widths (each decoded codepoint takes one
+# cell), no scrollback, writes at the last column clamp instead of autowrap,
+# and colour/attributes are ignored. That is enough for stable text asserts,
+# not for pixel-exact comparison.
+
+import difflib
+import fcntl
+import json
+import os
+import pty
+import select
+import shutil
+import signal
+import stat
+import struct
+import subprocess
+import sys
+import tempfile
+import termios
+import time
+from pathlib import Path
+
+# --------------------------------------------------------------------------
+# Expected strings (data, not code) -- extend scenarios here.
+
+ORG_NAME = "Test AB"
+ORG_NR = "5560123456"
+
+KEYS = {
+ "enter": "\r",
+ "esc": "\x1b",
+ "up": "\x1bOA", # application-mode arrows: curses enables keypad
+ "down": "\x1bOB",
+ "right": "\x1bOC",
+ "left": "\x1bOD",
+ "home": "\x1bOH",
+ "end": "\x1bOF",
+ "pgup": "\x1b[5~",
+ "pgdn": "\x1b[6~",
+ "ctrlc": "\x03",
+}
+
+# {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run
+# time. "keys" names come from KEYS (raw strings/literals work too).
+SCENARIOS = [
+ {
+ "name": "dashboard",
+ "screen": "dashboard",
+ "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}"],
+ },
+ {
+ "name": "audit-ok",
+ "screen": "audit",
+ "expect": ["Revision", "Verifiera hashkedjan"],
+ "steps": [
+ {
+ "keys": ["enter"],
+ "expect": ["Kedjorna är intakta.", "verifikat"],
+ },
+ ],
+ },
+ {
+ "name": "vouchers",
+ "screen": "vouchers",
+ "expect": ["Verifikat"],
+ },
+]
+
+# --------------------------------------------------------------------------
+# Tunables.
+
+TERM_COLS, TERM_ROWS = 120, 36
+QUIET = 0.25 # pty considered stable after this many quiet seconds
+STABLE_TIMEOUT = 8.0 # max time to wait for output to settle
+READY_TIMEOUT = 15.0 # max time to wait for the initial screen
+KEY_ATTEMPTS = 2 # bounded retry per key step (startup races)
+EXIT_TIMEOUT = 6.0 # max time to wait for the TUI to quit on ^C
+DAEMON_TIMEOUT = 20.0
+
+_ACS = {
+ "`": "◆", "a": "▒", "f": "°", "g": "±", "j": "┘", "k": "┐", "l": "┌",
+ "m": "└", "n": "┼", "o": "⎺", "p": "⎻", "q": "─", "r": "⎼", "s": "⎽",
+ "t": "├", "u": "┤", "v": "┴", "w": "┬", "x": "│", "y": "≤", "z": "≥",
+ "{": "π", "|": "≠", "}": "£", "~": "·",
+}
+
+
+def _params(arg):
+ if not arg:
+ return []
+ out = []
+ for part in arg.split(";"):
+ try:
+ out.append(int(part) if part else None)
+ except ValueError:
+ out.append(None)
+ return out
+
+
+class Terminal:
+ def __init__(self, cols, rows):
+ self.cols, self.rows = cols, rows
+ self.reset()
+
+ def reset(self):
+ self.cells = [[" "] * self.cols for _ in range(self.rows)]
+ self.cx = self.cy = 0
+ self.top, self.bottom = 0, self.rows - 1
+ self.saved = (0, 0)
+ self.acs = [False, False]
+ self.shifted = 0
+ self.state = "text"
+ self.buf = ""
+
+ def feed(self, text):
+ for ch in text:
+ self._feed(ch)
+
+ def _feed(self, ch):
+ st = self.state
+ if st == "text":
+ if ch == "\x1b":
+ self.state = "esc"
+ elif ch == "\r":
+ self.cx = 0
+ elif ch == "\n":
+ self._lf()
+ elif ch == "\b":
+ self.cx = max(0, self.cx - 1)
+ elif ch == "\t":
+ self.cx = min(self.cols - 1, (self.cx // 8 + 1) * 8)
+ elif ch == "\x0e":
+ self.shifted = 1
+ elif ch == "\x0f":
+ self.shifted = 0
+ elif ord(ch) >= 32 and ord(ch) != 127:
+ self._put(ch)
+ return
+ if st == "esc":
+ if ch == "[":
+ self.state, self.buf = "csi", ""
+ elif ch == "]":
+ self.state, self.buf = "osc", ""
+ elif ch == "(":
+ self.state = "g0"
+ elif ch == ")":
+ self.state = "g1"
+ elif ch == "7":
+ self.saved = (self.cy, self.cx)
+ self.state = "text"
+ elif ch == "8":
+ self.cy, self.cx = self.saved
+ self.state = "text"
+ elif ch == "D":
+ self._lf()
+ self.state = "text"
+ elif ch == "M":
+ self._ri()
+ self.state = "text"
+ elif ch == "E":
+ self.cx = 0
+ self._lf()
+ self.state = "text"
+ else:
+ self.state = "text"
+ return
+ if st in ("g0", "g1"):
+ self.acs[0 if st == "g0" else 1] = ch == "0"
+ self.state = "text"
+ return
+ if st == "csi":
+ if 0x40 <= ord(ch) <= 0x7e:
+ self._csi(self.buf, ch)
+ self.state = "text"
+ else:
+ self.buf += ch
+ return
+ if st == "osc":
+ if ch == "\x07":
+ self.state = "text"
+ elif ch == "\x1b":
+ self.state = "osc_esc"
+ return
+ if st == "osc_esc":
+ self.state = "text" if ch == "\\" else "osc"
+
+ def _csi(self, buf, final):
+ private = ""
+ i = 0
+ while i < len(buf) and buf[i] in "?><=!":
+ private += buf[i]
+ i += 1
+ p = _params(buf[i:])
+
+ def num(idx, default=1):
+ return p[idx] if idx < len(p) and p[idx] is not None else default
+
+ if private:
+ return # modes: cursor visibility, alt screen, keypad ... ignored
+ if final in "Hf":
+ self._move(num(0) - 1, num(1) - 1)
+ elif final == "A":
+ self._move(self.cy - num(0), self.cx)
+ elif final == "B":
+ self._move(self.cy + num(0), self.cx)
+ elif final == "C":
+ self._move(self.cy, self.cx + num(0))
+ elif final == "D":
+ self._move(self.cy, self.cx - num(0))
+ elif final == "E":
+ self._move(self.cy + num(0), 0)
+ elif final == "F":
+ self._move(self.cy - num(0), 0)
+ elif final in "G`":
+ self._move(self.cy, num(0) - 1)
+ elif final == "d":
+ self._move(num(0) - 1, self.cx)
+ elif final == "J":
+ self._erase_display(num(0, 0))
+ elif final == "K":
+ self._erase_line(num(0, 0))
+ elif final == "X":
+ self._erase_chars(num(0))
+ elif final == "P":
+ self._delete_chars(num(0))
+ elif final == "@":
+ self._insert_chars(num(0))
+ elif final == "r":
+ top = num(0) - 1
+ bottom = num(1) - 1 if len(p) > 1 and p[1] is not None else self.rows - 1
+ if 0 <= top < bottom < self.rows:
+ self.top, self.bottom = top, bottom
+ self._move(0, 0)
+ elif final == "L":
+ self._insert_lines(num(0))
+ elif final == "M":
+ self._delete_lines(num(0))
+ elif final == "S":
+ self._scroll_up(num(0))
+ elif final == "T":
+ self._scroll_down(num(0))
+ elif final == "s":
+ self.saved = (self.cy, self.cx)
+ elif final == "u":
+ self.cy, self.cx = self.saved
+ # "m" (SGR) and anything unknown: ignored
+
+ def _move(self, cy, cx):
+ self.cy = max(0, min(self.rows - 1, cy))
+ self.cx = max(0, min(self.cols - 1, cx))
+
+ def _put(self, ch):
+ if self.acs[self.shifted] and ch in _ACS:
+ ch = _ACS[ch]
+ if 0 <= self.cy < self.rows:
+ self.cells[self.cy][self.cx] = ch
+ if self.cx < self.cols - 1:
+ self.cx += 1
+
+ def _lf(self):
+ if self.cy == self.bottom:
+ self._scroll_up(1)
+ else:
+ self.cy = min(self.rows - 1, self.cy + 1)
+
+ def _ri(self):
+ if self.cy == self.top:
+ self._scroll_down(1)
+ else:
+ self.cy = max(0, self.cy - 1)
+
+ def _scroll_up(self, n):
+ for _ in range(max(1, n)):
+ for y in range(self.top, self.bottom):
+ self.cells[y] = self.cells[y + 1][:]
+ self.cells[self.bottom] = [" "] * self.cols
+
+ def _scroll_down(self, n):
+ for _ in range(max(1, n)):
+ for y in range(self.bottom, self.top, -1):
+ self.cells[y] = self.cells[y - 1][:]
+ self.cells[self.top] = [" "] * self.cols
+
+ def _erase_line(self, mode):
+ if mode == 0:
+ for x in range(self.cx, self.cols):
+ self.cells[self.cy][x] = " "
+ elif mode == 1:
+ for x in range(0, min(self.cx + 1, self.cols)):
+ self.cells[self.cy][x] = " "
+ else:
+ self.cells[self.cy] = [" "] * self.cols
+
+ def _erase_display(self, mode):
+ if mode == 0:
+ self._erase_line(0)
+ for y in range(self.cy + 1, self.rows):
+ self.cells[y] = [" "] * self.cols
+ elif mode == 1:
+ self._erase_line(1)
+ for y in range(0, self.cy):
+ self.cells[y] = [" "] * self.cols
+ else:
+ self.cells = [[" "] * self.cols for _ in range(self.rows)]
+
+ def _erase_chars(self, n):
+ for x in range(self.cx, min(self.cols, self.cx + max(1, n))):
+ self.cells[self.cy][x] = " "
+
+ def _delete_chars(self, n):
+ row = self.cells[self.cy]
+ del row[self.cx:self.cx + max(1, n)]
+ self.cells[self.cy] = row + [" "] * (self.cols - len(row))
+
+ def _insert_chars(self, n):
+ row = self.cells[self.cy]
+ self.cells[self.cy] = row[:self.cx] + [" "] * max(1, n) + row[self.cx:]
+ self.cells[self.cy] = self.cells[self.cy][:self.cols]
+
+ def _insert_lines(self, n):
+ if not (self.top <= self.cy <= self.bottom):
+ return
+ for _ in range(max(1, n)):
+ self.cells.insert(self.cy, [" "] * self.cols)
+ del self.cells[self.bottom + 1]
+
+ def _delete_lines(self, n):
+ if not (self.top <= self.cy <= self.bottom):
+ return
+ for _ in range(max(1, n)):
+ del self.cells[self.cy]
+ self.cells.insert(self.bottom, [" "] * self.cols)
+
+ def text(self):
+ return "\n".join("".join(row).rstrip() for row in self.cells)
+
+
+class HarnessError(Exception):
+ pass
+
+
+class PtyApp:
+ def __init__(self, argv, env, cols, rows):
+ self.argv, self.env = argv, env
+ self.cols, self.rows = cols, rows
+ self.raw = bytearray()
+ self.pid = self.fd = None
+ self.exited = False
+ self.status = None
+
+ def start(self):
+ pid, fd = pty.fork()
+ if pid == 0:
+ try:
+ os.execvpe(self.argv[0], self.argv, self.env)
+ except OSError:
+ os._exit(127)
+ self.pid, self.fd = pid, fd
+ fcntl.ioctl(fd, termios.TIOCSWINSZ,
+ struct.pack("HHHH", self.rows, self.cols, 0, 0))
+ return self
+
+ def pump(self, quiet=QUIET, timeout=STABLE_TIMEOUT):
+ last = time.time()
+ while time.time() - last < timeout:
+ ready, _, _ = select.select([self.fd], [], [], 0.05)
+ if ready:
+ try:
+ data = os.read(self.fd, 65536)
+ except OSError:
+ data = b""
+ if not data:
+ self.exited = True
+ return
+ self.raw += data
+ last = time.time()
+ elif time.time() - last >= quiet:
+ return
+
+ def render(self):
+ term = Terminal(self.cols, self.rows)
+ term.feed(bytes(self.raw).decode("utf-8", "replace"))
+ return term.text()
+
+ def send(self, data):
+ try:
+ os.write(self.fd, data)
+ except OSError as e:
+ raise HarnessError(f"could not write to pty: {e}")
+
+ def _reap(self):
+ try:
+ pid, status = os.waitpid(self.pid, os.WNOHANG)
+ except ChildProcessError:
+ self.exited = True
+ return True
+ if pid == self.pid:
+ self.exited, self.status = True, status
+ return True
+ return False
+
+ def close(self):
+ if self.fd is None:
+ return
+ try:
+ os.write(self.fd, KEYS["ctrlc"].encode())
+ except OSError:
+ pass
+ deadline = time.time() + EXIT_TIMEOUT
+ while time.time() < deadline and not self._reap():
+ ready, _, _ = select.select([self.fd], [], [], 0.05)
+ if ready:
+ try:
+ os.read(self.fd, 65536)
+ except OSError:
+ pass
+ if not self.exited:
+ try:
+ os.killpg(self.pid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ try:
+ os.kill(self.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ try:
+ os.waitpid(self.pid, 0)
+ except ChildProcessError:
+ pass
+ try:
+ os.close(self.fd)
+ except OSError:
+ pass
+ self.fd = None
+
+
+def run_checked(cmd, env):
+ p = subprocess.run(cmd, env=env, stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True)
+ if p.returncode != 0:
+ raise HarnessError(f"{' '.join(cmd[:2])} failed "
+ f"({p.returncode}):\n{p.stdout.strip()}")
+ return p.stdout
+
+
+def ensure_build(repo):
+ need = [n for n in ("bokfd", "bokftui", "bokfctl")
+ if not os.access(repo / "build" / n, os.X_OK)]
+ if not need:
+ return
+ print(f"building missing binaries: {', '.join(need)}", flush=True)
+ p = subprocess.run(["make", f"-j{os.cpu_count() or 1}"], cwd=str(repo))
+ if p.returncode != 0:
+ raise HarnessError("make failed")
+ for n in need:
+ if not os.access(repo / "build" / n, os.X_OK):
+ raise HarnessError(f"make did not produce build/{n}")
+
+
+def wait_for(app, needles, timeout):
+ deadline = time.time() + timeout
+ screen = ""
+ while True:
+ app.pump()
+ screen = app.render()
+ missing = [n for n in needles if n not in screen]
+ if not missing or app.exited or time.time() >= deadline:
+ return missing, screen
+ time.sleep(0.05)
+
+
+def format_expect(strings, ctx):
+ return [s.format(**ctx) for s in strings]
+
+
+def resolve_keys(spec):
+ if isinstance(spec, str):
+ spec = [spec]
+ return "".join(KEYS.get(k, k) for k in spec).encode("utf-8")
+
+
+def report_failure(name, missing, before, current):
+ print(f"FAIL {name}")
+ for m in missing:
+ print(f" missing: {m!r}")
+ diff = list(difflib.unified_diff(
+ before.splitlines(), current.splitlines(),
+ fromfile="screen before", tofile="screen after", lineterm="", n=2))
+ if diff:
+ print(" --- screen diff ---")
+ for line in diff:
+ print(" " + line)
+ else:
+ print(" (screen unchanged)")
+ print(" --- current screen ---")
+ for i, line in enumerate(current.splitlines(), 1):
+ print(f" {i:>3}|{line}")
+ print(flush=True)
+
+
+def setup_rig(repo, tmp, env):
+ bind, db = repo / "build", tmp / "t.db"
+ sock = tmp / "sock"
+ run_checked([str(bind / "bokfd"), "init", "--db", str(db),
+ "--user", "admin"], env)
+ log = tmp / "daemon.log"
+ daemon = subprocess.Popen(
+ [str(bind / "bokfd"), "--db", str(db), "--socket", str(sock),
+ "--log-level", "info"],
+ env=env, stdin=subprocess.DEVNULL, stdout=open(log, "wb"),
+ stderr=subprocess.STDOUT, start_new_session=True)
+ deadline = time.time() + DAEMON_TIMEOUT
+ while time.time() < deadline:
+ if daemon.poll() is not None:
+ raise HarnessError("daemon exited during startup:\n" +
+ log.read_text(errors="replace").strip())
+ try:
+ if stat.S_ISSOCK(os.stat(sock).st_mode):
+ break
+ except OSError:
+ pass
+ time.sleep(0.05)
+ else:
+ raise HarnessError(f"daemon socket {sock} did not appear")
+
+ out = run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "org.create",
+ json.dumps({"name": ORG_NAME, "org_nr": ORG_NR})], env)
+ org = json.loads(out)["result"]
+ org_id, fy_id = org["id"], org["fiscal_year_id"]
+ out = run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "--org", str(org_id), "fiscal_year.get",
+ json.dumps({"id": fy_id})], env)
+ fy = json.loads(out)["result"]
+ return daemon, sock, org_id, fy
+
+
+def main(argv):
+ opts = set(argv)
+ if "--help" in opts or "-h" in opts:
+ print("usage: tui-golden.py [--keep] [--verbose] [--only NAME]")
+ return 0
+ verbose = "--verbose" in opts
+ keep = "--keep" in opts
+ only = None
+ rest = list(argv)
+ while rest:
+ a = rest.pop(0)
+ if a == "--only" and rest:
+ only = rest.pop(0)
+ elif a not in ("--keep", "--verbose", "--only"):
+ print(f"unknown option: {a}", file=sys.stderr)
+ return 2
+
+ repo = Path(__file__).resolve().parent.parent
+ tmp = Path(tempfile.mkdtemp(prefix="bokf-golden-", dir="/tmp"))
+ password = "golden-" + os.urandom(6).hex()
+ env = dict(os.environ)
+ env.update({"BOKFD_PASSWORD": password})
+ env.pop("BOKFD_SESSION", None)
+ env.pop("BOKFD_TOKEN", None)
+
+ daemon = None
+ failures = 0
+ keep_note = keep
+ try:
+ ensure_build(repo)
+ daemon, sock, org_id, fy = setup_rig(repo, tmp, env)
+ ctx = {
+ "org_name": ORG_NAME,
+ "org_nr": ORG_NR,
+ "fy_label": fy.get("label", ""),
+ "fy_start": fy.get("start_date", ""),
+ "fy_end": fy.get("end_date", ""),
+ }
+ print(f"bokf TUI golden tests: org {org_id} \"{ORG_NAME}\", "
+ f"fy {fy.get('label')} (id {fy.get('id')}), socket {sock}")
+ env.update({
+ "TERM": "xterm-256color",
+ "LC_ALL": "C.UTF-8",
+ "BOKFD_USER": "admin",
+ })
+ sandbox = repo / "scripts" / "tui-sandbox.sh"
+ bokftui = repo / "build" / "bokftui"
+ selected = [sc for sc in SCENARIOS if not only or sc["name"] == only]
+ if only and not selected:
+ raise HarnessError(f"unknown scenario: {only}")
+ for sc in selected:
+ argv_tui = [str(sandbox), "--", str(bokftui),
+ "--socket", str(sock), "--user", "admin",
+ "--org", str(org_id), "--fy", str(fy["id"]),
+ "--screen", sc["screen"]]
+ app = PtyApp(argv_tui, env, TERM_COLS, TERM_ROWS)
+ name = sc["name"]
+ missing = None
+ before = current = ""
+ try:
+ app.start()
+ missing, before = wait_for(
+ app, format_expect(sc.get("expect", []), ctx), READY_TIMEOUT)
+ if missing:
+ report_failure(name, missing, "", before)
+ failures += 1
+ continue
+ current = before
+ for step in sc.get("steps", []):
+ keys = resolve_keys(step["keys"])
+ want = format_expect(step.get("expect", []), ctx)
+ for attempt in range(1, KEY_ATTEMPTS + 1):
+ before = app.render()
+ app.send(keys)
+ app.pump()
+ current = app.render()
+ missing = [n for n in want if n not in current]
+ if not missing:
+ break
+ # Retry only when the key left the screen untouched
+ # (startup race); a visible change means it arrived.
+ if current == before and attempt < KEY_ATTEMPTS:
+ print(f" {name}: retrying key step "
+ f"{step['keys']!r} (screen unchanged)")
+ continue
+ break
+ if missing:
+ report_failure(name, missing, before, current)
+ failures += 1
+ break
+ if not missing:
+ print(f"PASS {name}")
+ if verbose:
+ for line in current.splitlines():
+ print(f" |{line}")
+ finally:
+ app.close()
+ except HarnessError as e:
+ print(f"HARNESS ERROR: {e}", file=sys.stderr)
+ if daemon and daemon.poll() is not None:
+ print((tmp / "daemon.log").read_text(errors="replace"),
+ file=sys.stderr)
+ failures = max(failures, 1)
+ finally:
+ if daemon and daemon.poll() is None:
+ try:
+ os.killpg(daemon.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ daemon.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(daemon.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ daemon.wait()
+ if keep_note:
+ print(f"kept temp dir: {tmp}")
+ else:
+ shutil.rmtree(tmp, ignore_errors=True)
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))