diff --git a/CHANGELOG.md b/CHANGELOG.md index 3453a88..8accdf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project are documented here. The format follows ### Added +- Custom integrations: any number of `[[integrations]]` sections, each a command whose + output devdash shows at `top`, `after-usage`, `after-my-prs`, or `bottom`. Each runs on + its own thread and schedule, with a timeout that kills its whole process group, and a + failure keeps its last good output on screen under the error. `--no-integration NAME` + hides one integration and `--no-integrations` hides all of them. - `--profile NAME` selects the matching isolated omp profile for usage and cached readings; only that profile's authenticated providers appear. Provider headings include that provider's account email in white, for example `Codex (user@example.com)`. diff --git a/README.md b/README.md index 823953e..23c97d6 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ named `devdash`. `nous` credential. Hidden when `omp` is not installed. Weekly and monthly bars show a pace icon (`>>` too fast, `<<` too slow, `|` on pace). - **Hide what you do not want to see.** Exclude single repositories or whole organizations. +- **Your own sections.** Add as many custom integrations as you like (weather, service health, + a tutor, a build queue): any command whose output devdash shows in a slot you choose. PR numbers are clickable in terminals that support hyperlinks. @@ -39,6 +41,7 @@ PR numbers are clickable in terminals that support hyperlinks. - [Install](#install) - [Usage](#usage) - [Configuration](#configuration) +- [Custom integrations](#custom-integrations) - [Troubleshooting](#troubleshooting) - [Related projects](#related-projects) - [Contributing](#contributing) @@ -78,6 +81,7 @@ devdash --once # print one frame and exit devdash --exclude acme/monorepo # hide one repository devdash --exclude acme # hide every repository of a user or organization devdash --no-review --no-usage # show only your own PRs +devdash --no-integration weather # hide one custom integration (--no-integrations hides all) devdash --profile personal --once # show usage from the personal omp profile devdash --github alice # MY PRS / REVIEW REQUESTED for this gh login devdash --help # all flags @@ -159,6 +163,95 @@ Command-line flags override the file, and each `--exclude` adds to `github.exclu key or a value of the wrong type stops devdash with an error, so a typo cannot fail silently. [`config.example.toml`](config.example.toml) lists every key with its default. +## Custom integrations + +An integration is your own section. Declare one `[[integrations]]` table per section; there is +no limit on how many. devdash runs each one's command and shows what it prints to standard +output, under a heading: + +```toml +[[integrations]] +name = "weather" # required; unique; used by --no-integration +title = "WEATHER" # heading; defaults to the name in capitals +command = ["curl", "-fsS", "https://wttr.in/Toronto?format=3"] +position = "top" # top, after-usage, after-my-prs, or bottom (the default) +interval = 900 # seconds between runs, up to 86400; 0 (the default) uses the global interval +timeout = 10 # seconds, 1 to 86400, before the command and its children are killed +max_rows = 20 # longer output is cut, ending in "… N more rows" +enabled = true # false keeps the table but hides the section +env = { WTTR_LANG = "en" } # extra environment variables for the command +``` + +Integrations that share a position appear in the order they are listed in the file. + +**What a command must do.** Print the section body and exit with status 0. Any language works: +a shell script, a Python file, or an existing CLI with a one-shot mode. devdash sets `COLUMNS` +to the pane width and `LINES` to `max_rows`, so the command can fit its output to the room it has; +when the pane is resized, devdash runs every waiting integration again at once (one that is +already running finishes first). Colors (SGR) and hyperlinks (OSC 8) are kept. Cursor movement, +screen clears, and every other escape sequence are dropped, and any other control character +shows as `�`. A link whose target contains a control character loses its link. Rows longer +than the pane end in `…`. Trailing blank rows are removed. devdash keeps +the first 64 KiB of standard output: a command that prints more is stopped there, and that part +is shown. Of standard error it keeps only the last 4 KiB, so a noisy command cannot use up memory. + +**Failures.** A non-zero exit, a timeout, or a command that cannot start shows `⚠` and the +reason under the heading: for a non-zero exit, the last line of its standard error, as plain +text. The last good output stays on screen below the error, and the heading tells you how old it is. + +**Safety.** An integration runs with your user's rights. Only list commands you trust. +devdash runs the `command` list directly, without a shell; to use pipes or `&&`, write +`["sh", "-c", "…"]` or point at a script. The first element may start with `~`. Each +integration runs on its own thread, with no standard input and in its own process group, so a +slow or hung command cannot freeze the dashboard or read your keystrokes. A timeout kills the +command together with every process it started that stayed in its process group; a program +that starts its own session (a daemon, for example) escapes this. Quitting devdash, with `q`, +Ctrl-C, or by closing the pane, kills running commands the same way. Pressing `r` runs every +waiting integration again at once. With `--once`, devdash runs all integrations in parallel, +each within its own timeout, alongside the built-in GitHub and usage reads. + +### Example: prompt-tutor + +[prompt-tutor](https://github.com/shayan-ys/prompt-tutor) reviews the English of the prompts you +send to omp, and its Watcher shows the latest Review. Its one-shot mode, `prompt-tutor --once`, +prints one frame sized to `COLUMNS` and `LINES` and exits, so it works as an integration as is. + +1. Install prompt-tutor and put its Watcher on your `PATH`. Until prompt-tutor's first build is + merged into its `main` branch, `main` holds only planning documents, so install the + `build/v0` branch: + + ```sh + omp plugin install 'github:shayan-ys/prompt-tutor#build/v0' + ``` + + Restart omp, then run `/prompt-tutor install-watcher` inside omp. After the merge, follow + prompt-tutor's [install instructions](https://github.com/shayan-ys/prompt-tutor#install), + which install from `main`. +2. Check that the frame fits a narrow pane: + + ```sh + COLUMNS=50 LINES=16 prompt-tutor --once < /dev/null | cat + ``` + +3. Add it to your devdash config (`~/.config/devdash/config.toml` by default), here at the bottom: + + ```toml + [[integrations]] + name = "prompt-tutor" + title = "PROMPT TUTOR" + command = ["prompt-tutor", "--once"] + position = "bottom" + max_rows = 16 + ``` + + If devdash shows `⚠ cannot run prompt-tutor`, the Watcher link is not on the `PATH` that + devdash sees; use its full path, for example `command = ["~/.local/bin/prompt-tutor", "--once"]`. +4. Run `devdash`. The section updates on the global interval; press `r` to refresh it after a + prompt, and use `devdash --no-integration prompt-tutor` to hide it for one run. + +The frame's last row lists the Watcher's own keys (`j/k`, `s`, `q`). They work only in the +standalone `prompt-tutor` Watcher, not inside devdash. + ## Troubleshooting **`gh is not signed in`.** Run `gh auth login`. With more than one account, pass `--github USER` @@ -186,6 +279,14 @@ the dashboard cache. Check that the key belongs to the selected profile and rema (or `nous`). A browser or Hermes login is not visible to omp; store the Portal credential in the selected omp profile first. Inference keys that cannot read the account API are omitted. +**An integration shows `⚠ exit 127` or `cannot run …`.** devdash could not find the command. +Use an absolute path, or check that the program is on the `PATH` that devdash sees. + +**An integration's layout is broken.** The command probably draws for a full-screen terminal. +Run it as devdash does, for example `COLUMNS=50 LINES=20 your-command < /dev/null | cat`, and +check that it fits. A command that sizes itself from the terminal must fall back to `COLUMNS` +and `LINES` when its output is a pipe. + ## Related projects - [gh-dash](https://github.com/dlvhdr/gh-dash) is a full-screen, interactive dashboard for PRs diff --git a/config.example.toml b/config.example.toml index 41f749b..fc513f5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -37,3 +37,16 @@ order = [] # e.g. ["openai-codex", "anthropic"] [usage.colors] # anthropic = "#d97757" + +# Custom integrations: your own sections. Add one [[integrations]] table per section, as many as you like. +# devdash runs `command` (a list, run without a shell) and shows what it prints. See README "Custom integrations". +# [[integrations]] +# name = "weather" # required, unique; `--no-integration weather` hides it +# title = "WEATHER" # heading; default: the name in capitals +# command = ["curl", "-fsS", "https://wttr.in/?format=3"] +# position = "bottom" # "top" | "after-usage" | "after-my-prs" | "bottom" +# interval = 0 # seconds between runs, up to 86400; 0 = the global interval +# timeout = 10 # seconds (1 to 86400) before the command and its children are killed +# max_rows = 20 # longer output is cut +# enabled = true +# env = {} # extra environment variables, e.g. { CITY = "Toronto" } diff --git a/devdash.py b/devdash.py index bb393e2..ac29456 100755 --- a/devdash.py +++ b/devdash.py @@ -11,6 +11,8 @@ and PRs where your review is requested - `omp usage --json` every AI provider's limits and reset times (optional; shown only when `omp` is installed) + - [[integrations]] commands your own sections: any command whose output + devdash shows (see the README) Settings come from a TOML file (see --config); command-line flags win. Keys while watching: r = refresh now, q = quit. @@ -18,15 +20,19 @@ import argparse import calendar +import contextlib import hashlib import json import os import re import select +import selectors import shutil +import signal import subprocess import sys import termios +import threading import time import tomllib import tty @@ -36,7 +42,8 @@ from rich.console import Console, Group from rich.live import Live -from rich.text import Text +from rich.style import Style +from rich.text import Span, Text __version__ = "0.1.0" CONSOLE = Console() @@ -112,6 +119,7 @@ def line(*parts): "interval": 60, "github": {"exclude": [], "review_requested": True, "account": ""}, "usage": {"enabled": "auto", "refetch_after": 180, "pace": True, "order": [], "names": {}, "colors": {}}, + "integrations": [], } REPO_RULE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$") @@ -152,6 +160,7 @@ def load_config(path, explicit): cfg = merge(DEFAULTS, raw) if cfg["usage"]["enabled"] not in (True, False, "auto"): raise SystemExit("devdash: config key 'usage.enabled' must be true, false, or \"auto\"") + cfg["integrations"] = check_integrations(cfg["integrations"]) return cfg @@ -797,10 +806,9 @@ def rule(width): return line(("─" * width, RULE)) -def render_prs(st, width, now): - out = [rule(width)] if st.show_usage else [] - out.append(line(("MY PRS ", H1), (str(len(st.mine)), H1), - (" fetched " + dur(now - st.prs_at) + " ago", META) if st.prs_at else "")) +def render_mine(st, width, now): + out = [line(("MY PRS ", H1), (str(len(st.mine)), H1), + (" fetched " + dur(now - st.prs_at) + " ago", META) if st.prs_at else "")] if st.prs_err: out.append(line((f"⚠ {st.prs_err}", BAD))) for repo, groups in group_mine(st.mine).items(): @@ -823,9 +831,11 @@ def render_prs(st, width, now): state = pr.get("state", "").lower() note = "✓merged" if state == "merged" else state out += title_rows(pr, width, bar, dim=True, note=note)[:1] - if not st.show_review: - return out - out += [Text(), rule(width), line(("REVIEW REQUESTED ", H1), (str(len(st.review)), H1)), Text()] + return out + + +def render_review(st, width, now): + out = [line(("REVIEW REQUESTED ", H1), (str(len(st.review)), H1)), Text()] if not st.review: out.append(line(("nothing waiting on you", META))) for ri, pr in enumerate(sorted(st.review, key=lambda p: p["updatedAt"], reverse=True)): @@ -841,6 +851,220 @@ def render_prs(st, width, now): return out +# ── custom integrations ─────────────────────────────────────────────────────── +# An integration is any command that prints its section body to stdout. devdash +# runs it without a shell, on its own thread and schedule, with no stdin and a +# timeout, and shows its last good output; colours and hyperlinks are kept, and +# every other escape sequence is dropped. +POSITIONS = ("top", "after-usage", "after-my-prs", "bottom") +INTEGRATION = {"name": "", "title": "", "command": [], "position": "bottom", "enabled": True, + "interval": 0, "timeout": 10, "max_rows": 20, "env": {}} +NAME_RULE = re.compile(r"[A-Za-z0-9_.-]+") +MAX_OUTPUT = 64 * 1024 # bytes of standard output devdash keeps; a command that prints more is stopped +ERR_TAIL = 4 * 1024 # bytes of standard error devdash keeps, the newest ones, for the error line +MAX_SECONDS = DAY # upper bound for interval and timeout; threading and select reject huge waits +# C0 controls except tab and newline, DEL, and C1 controls: what is left of escape sequences +# that Rich does not decode. Each one becomes U+FFFD so styled spans keep their offsets. +CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") + + +def check_integrations(items): + """Validate the [[integrations]] tables, disabled ones included, and fill in their defaults.""" + specs, names = [], set() + for i, raw in enumerate(items): + if not isinstance(raw, dict): + raise SystemExit(f"devdash: integrations[{i}] must be a table") + spec = merge(INTEGRATION, raw, f"integrations[{i}].") + name = spec["name"] + if not isinstance(name, str) or not NAME_RULE.fullmatch(name): + raise SystemExit(f"devdash: integrations[{i}].name is required: letters, digits, '.', '_' or '-'") + if name in names: + raise SystemExit(f"devdash: integration name '{name}' is used twice") + names.add(name) + where = f"devdash: integration '{name}':" + cmd = spec["command"] + if not cmd or not all(isinstance(a, str) and a for a in cmd): + raise SystemExit(f"{where} command must be a non-empty list of strings") + if not isinstance(spec["title"], str): + raise SystemExit(f"{where} title must be a string") + if spec["position"] not in POSITIONS: + raise SystemExit(f"{where} position must be one of {', '.join(POSITIONS)}") + if not 0 <= spec["interval"] <= MAX_SECONDS: + raise SystemExit(f"{where} interval must be 0 (the global interval) to {MAX_SECONDS}") + if not 1 <= spec["timeout"] <= MAX_SECONDS: + raise SystemExit(f"{where} timeout must be 1 to {MAX_SECONDS}") + if spec["max_rows"] < 1: + raise SystemExit(f"{where} max_rows must be 1 or more") + if not all(isinstance(v, str) for v in spec["env"].values()): + raise SystemExit(f"{where} env values must be strings") + specs.append(spec) + return specs + + +def output_rows(text, max_rows): + """A command's ANSI output as screen rows, trailing blank rows dropped, at most `max_rows`. + + Only SGR styles and OSC 8 links survive: Rich decodes those, the leftover control + characters are replaced, and a link whose target holds a control character is dropped.""" + body = Text.from_ansi(text.replace("\r\n", "\n")) + body.plain = CONTROL.sub("\ufffd", body.plain) + body.spans = [Span(s.start, s.end, s.style.update_link(None)) + if isinstance(s.style, Style) and s.style.link and CONTROL.search(s.style.link) else s + for s in body.spans] + body.expand_tabs() + rows = [line(r) for r in body.split("\n")] + while rows and not rows[-1].plain.strip(): + rows.pop() + if len(rows) > max_rows: + hidden = len(rows) - max_rows + 1 + rows = rows[:max_rows - 1] + [line((f"… {hidden} more rows", META))] + return rows + + +def plain_line(text): + """`text` as one row of plain characters, for messages built from a command's stderr.""" + return CONTROL.sub("", Text.from_ansi(text).plain.replace("\n", " ")) + + +def kill_group(p): + with contextlib.suppress(ProcessLookupError): + os.killpg(p.pid, signal.SIGKILL) + p.wait() + + +def capture(p, deadline): + """(stdout, stderr tail, stopped for size) of `p`, holding at most MAX_OUTPUT + ERR_TAIL bytes. + + Reads both pipes as data arrives, so a command that floods either one cannot grow + devdash's memory. Stops the process group once stdout passes MAX_OUTPUT, or raises + TimeoutExpired after stopping it at `deadline`. + """ + out, err = bytearray(), bytearray() + full = False + with selectors.DefaultSelector() as sel: + sel.register(p.stdout, selectors.EVENT_READ, out) + sel.register(p.stderr, selectors.EVENT_READ, err) + while sel.get_map() and not full: + left = deadline - time.monotonic() + if left <= 0: + kill_group(p) + raise subprocess.TimeoutExpired(p.args, 0) + for key, _ in sel.select(left): + chunk = os.read(key.fd, 65536) + if not chunk: + sel.unregister(key.fileobj) + elif key.data is err: + err += chunk + del err[:-ERR_TAIL] + else: + out += chunk + full = len(out) > MAX_OUTPUT + if full: + kill_group(p) + return out[:MAX_OUTPUT], err, True + try: # both pipes are closed, but the command may still be running + p.wait(max(0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + kill_group(p) + raise + return out, err, False + + +class Integration: + """One configured section. Its own thread publishes `result`; the render thread reads it.""" + + def __init__(self, spec, interval): + self.name = spec["name"] + self.title = spec["title"] or spec["name"].upper() + self.command = [os.path.expanduser(spec["command"][0]), *spec["command"][1:]] + self.position = spec["position"] + self.interval = min(spec["interval"] or interval, MAX_SECONDS) # the global interval is unbounded + self.timeout = spec["timeout"] + self.max_rows = spec["max_rows"] + self.env = spec["env"] + self.result = ([], 0, None) # (rows, fetched at, error) + self.wake = threading.Event() + # stop() and fetch() share the running command under this lock, so a command + # started while devdash quits is either never started or killed. + self.lock = threading.Lock() + self.proc = None + self.stopped = False + + def fetch(self, width): + # COLUMNS and LINES tell the command how much room its section has. + env = {**os.environ, **self.env, "COLUMNS": str(width), "LINES": str(self.max_rows)} + with self.lock: + if self.stopped: + raise RuntimeError("stopped") + try: + # A new session puts the command and its children in one process group, + # so a timeout kills them all; DEVNULL keeps it off devdash's keyboard. + p = subprocess.Popen(self.command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env, start_new_session=True) + except OSError as e: + raise RuntimeError(f"cannot run {self.command[0]}: {e.strerror}") from None + self.proc = p + try: + out, err, full = capture(p, time.monotonic() + self.timeout) + except subprocess.TimeoutExpired: + raise RuntimeError(f"timed out after {self.timeout}s") from None + except BaseException: + if p.returncode is None: # never leave a command running behind an unexpected error + kill_group(p) + raise + finally: + with self.lock: + self.proc = None + p.stdout.close() + p.stderr.close() + if p.returncode and not full: # a command stopped for printing too much still shows its output + tail = err.decode(errors="replace").strip().splitlines() + raise RuntimeError(f"exit {p.returncode}" + (f": {tail[-1]}" if tail else "")) + return output_rows(out.decode(errors="replace"), self.max_rows) + + def poll(self, width): + """Run the command once. A failure keeps the last good rows on screen under the error. + + `result` is (rows, fetched at, error), replaced as one tuple so a frame never mixes two runs.""" + try: + self.result = (self.fetch(width), time.time(), None) + except Exception as e: # one broken integration must never stop the dashboard + rows, at, _ = self.result + self.result = (rows, at, plain_line(str(e))[:80]) + + def loop(self, st): + while not self.stopped: + self.wake.clear() + self.poll(st.width) + self.wake.wait(self.interval) + + def stop(self): + """Stop polling and kill a running command with everything it started. + + The command runs in its own session, so it would outlive devdash otherwise.""" + with self.lock: + self.stopped = True + p = self.proc + self.wake.set() + if p is not None: + with contextlib.suppress(ProcessLookupError): + os.killpg(p.pid, signal.SIGKILL) + + +def render_integration(ig, now): + rows, at, err = ig.result + if at: + age = (" fetched " + dur(now - at) + " ago", META) + else: + age = (" loading…", META) if not err else "" + out = [line((ig.title, H1), age)] + if err: + out.append(line((f"⚠ {err}", BAD))) + return out + rows + + + + # ── loop ────────────────────────────────────────────────────────────────────── class State: usage = None @@ -860,6 +1084,8 @@ class State: show_review = True show_pace = True excluded = [] + integrations = [] + width = 80 # the pane width at the last render, passed to integrations as COLUMNS class Dashboard: @@ -870,11 +1096,22 @@ def __init__(self, st): def __rich_console__(self, console, options): now = time.time() + st = self.st width = options.max_width - rows = render_usage(self.st, width, now) + [Text()] if self.st.show_usage else [] - rows += render_prs(self.st, width, now) - foot = f"{time.strftime('%H:%M:%S')} · every {self.st.interval}s · r refresh · q quit" - if self.st.busy: + if width != st.width: # integrations lay out for the new width now, not at their next run + st.width = width + for ig in st.integrations: + ig.wake.set() + slot = {p: [render_integration(ig, now) for ig in st.integrations if ig.position == p] + for p in POSITIONS} + sections = slot["top"] + ([render_usage(st, width, now)] if st.show_usage else []) + sections += slot["after-usage"] + [render_mine(st, width, now)] + slot["after-my-prs"] + sections += ([render_review(st, width, now)] if st.show_review else []) + slot["bottom"] + rows = list(sections[0]) + for rows_of in sections[1:]: + rows += [Text(), rule(width), *rows_of] + foot = f"{time.strftime('%H:%M:%S')} · every {st.interval}s · r refresh · q quit" + if st.busy: foot = "refreshing… · " + foot height = options.height or console.height if len(rows) > height - 2: @@ -928,6 +1165,9 @@ def parse_args(): "adds to github.exclude") ap.add_argument("--no-review", action="store_true", help="hide the REVIEW REQUESTED section") ap.add_argument("--no-usage", action="store_true", help="hide the USAGE section") + ap.add_argument("--no-integration", action="append", default=[], metavar="NAME", + help="hide the custom integration NAME; repeatable") + ap.add_argument("--no-integrations", action="store_true", help="hide every custom integration") # Some providers' usage endpoints rate-limit hard, and omp drops a provider # instead of keeping its last report, so forced refetches are rationed. ap.add_argument("--usage-every", type=int, metavar="SECONDS", @@ -962,6 +1202,13 @@ def main(): usage_every = args.usage_every if args.usage_every is not None else usage["refetch_after"] if args.cached or usage_every == 0: usage_every = None + hidden = set(args.no_integration) + unknown = hidden - {s["name"] for s in cfg["integrations"]} + if unknown: + raise SystemExit(f"devdash: --no-integration: no integration named '{sorted(unknown)[0]}'") + if not args.no_integrations: + st.integrations = [Integration(s, st.interval) for s in cfg["integrations"] + if s["enabled"] and s["name"] not in hidden] # Start from omp's cache so the first frame never waits on, or loses, a # refetch; the saved last good reads fill any provider omp dropped. @@ -979,16 +1226,42 @@ def main(): raise SystemExit(f"devdash: gh is not signed in{who} ({e}){hint}") from None pool = ThreadPoolExecutor(2) - if args.once or not sys.stdin.isatty(): + st.width = CONSOLE.width + # Closing the pane sends SIGHUP; exit through `finally` so running integrations are killed. + for sig in (signal.SIGHUP, signal.SIGTERM): + signal.signal(sig, lambda *_: sys.exit(0)) + try: + if args.once or not sys.stdin.isatty(): + print_once(st, pool, usage_every) + else: + watch(st, pool, usage_every) + except KeyboardInterrupt: + pass + finally: + for ig in st.integrations: + ig.stop() + + +def print_once(st, pool, usage_every): + runner = ThreadPoolExecutor(max(1, len(st.integrations))) + try: + jobs = [runner.submit(ig.poll, st.width) for ig in st.integrations] refresh(st, pool, usage_every, False) - CONSOLE.print(Dashboard(st), height=10_000) - return + for job in jobs: + job.result() + finally: + runner.shutdown(wait=False) # on an early exit, main stops the commands these workers wait on + CONSOLE.print(Dashboard(st), height=10_000) + +def watch(st, pool, usage_every): fd = sys.stdin.fileno() saved = termios.tcgetattr(fd) tty.setcbreak(fd) - force = False try: + for ig in st.integrations: + threading.Thread(target=ig.loop, args=(st,), daemon=True, name=f"integration-{ig.name}").start() + force = False with Live(Dashboard(st), console=CONSOLE, screen=True, refresh_per_second=1): while True: refresh(st, pool, usage_every, force) @@ -1001,9 +1274,9 @@ def main(): return if key == "r": force = True + for ig in st.integrations: + ig.wake.set() break - except KeyboardInterrupt: - pass finally: termios.tcsetattr(fd, termios.TCSADRAIN, saved) diff --git a/tests/test_devdash.py b/tests/test_devdash.py index b6501e1..d1435ef 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -1,6 +1,11 @@ +import io import json +import os +import sys +import time import pytest +from rich.console import Console import devdash @@ -43,6 +48,18 @@ def test_config_merges_over_defaults(tmp_path): "[github]\nreview_requested = 1\n", # number where a boolean belongs "interval = true\n", # boolean where a number belongs "[usage]\nenabled = \"sometimes\"\n", + "[[integrations]]\ncommand = [\"date\"]\n", # no name + "[[integrations]]\nname = \"a b\"\ncommand = [\"date\"]\n", # name unusable as a flag value + "[[integrations]]\nname = \"a\\n\"\ncommand = [\"date\"]\n", # trailing newline + "[[integrations]]\nname = \"a\"\ncommand = []\n", + "[[integrations]]\nname = \"a\"\ncommand = \"date\"\n", # a shell string, not argv + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nposition = \"middle\"\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\ntimeout = 0\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\ntimeout = 9223372036854775807\n", # too big to wait on + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\ninterval = 9223372036854775807\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nenv = { N = 1 }\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nintervl = 5\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\n[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\n", ]) def test_config_rejects_mistakes(tmp_path, text): with pytest.raises(SystemExit): @@ -471,3 +488,167 @@ def test_usage_hides_disabled_credentials_but_shows_authenticated_accounts_witho def test_pace_icon_sits_beside_a_value_that_never_moves(points, row): icon = devdash.pace_icon(points) if points is not None else None assert devdash.meter(0.0, 20, "34%", icon=icon).plain == row + + +# ── custom integrations ─────────────────────────────────────────────────────── +def integration(code, **spec): + """An integration whose command is a Python snippet, so tests need no shell tools.""" + return devdash.Integration({**devdash.INTEGRATION, "name": "t", **spec, + "command": [sys.executable, "-c", code]}, 60) + + +def gone(pid, wait=5): + """True once `pid` has exited within `wait` seconds; a zombie waiting to be reaped counts as exited.""" + end = time.monotonic() + wait + while time.monotonic() < end: + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + state = devdash.subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True) + if not state.stdout.strip() or state.stdout.strip().startswith("Z"): + return True + time.sleep(0.05) + return False + + +def spawner(pid_file, sleep=60): + """Python that starts a sleeping child, records the child's pid, then sleeps itself.""" + return ("import subprocess, sys, time\n" + f"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep({sleep})'])\n" + f"open({str(pid_file)!r}, 'w').write(str(child.pid))\n" + f"time.sleep({sleep})") + + +def wait_for_pid(pid_file): + for _ in range(200): + if pid_file.exists() and pid_file.read_text(): + return int(pid_file.read_text()) + time.sleep(0.05) + pytest.fail("the command never started its child") + + +def test_integration_keeps_colour_and_links_drops_screen_control_and_gets_its_room(): + ig = integration( + "import os; print('\\x1b[2J\\x1b[H\\x1b[1mbold\\x1b[0m '" + " + '\\x1b]8;;https://x.test\\x1b\\\\link\\x1b]8;;\\x1b\\\\'" + " + ' ' + os.environ['COLUMNS'] + 'x' + os.environ['LINES'] + ' ' + os.environ['GREETING'])", + max_rows=7, env={"GREETING": "hi"}) + ig.poll(42) + [row], at, err = ig.result + assert err is None and at + assert row.plain == "bold link 42x7 hi" + assert {(row.plain[s.start:s.end], str(s.style)) for s in row.spans} >= { + ("bold", "bold"), ("link", "link https://x.test")} + + +def test_integration_output_cannot_smuggle_terminal_control(): + hostile = ("\x1b[31mred\x1b[0m \x01\x1b\x9b2J \x1b]8;;https://x.test/\x9b2J\x1b\\bad link\x1b]8;;\x1b\\" + " \x1b]8;;\x07\x1b[2J\x1b\\gone\r\n") + out = io.StringIO() + console = Console(file=out, force_terminal=True, color_system="truecolor", width=80, + _environ={"TERM": "xterm-256color"}) + for row in devdash.output_rows(hostile, 5): + console.print(row) + emitted = out.getvalue() + assert "\x1b[31m" in emitted # colour survives + for bad in ("\x01", "\x9b", "\x07", "\x1b[2J", "\x1b]8;;https://x.test/"): + assert bad not in emitted + assert "bad link" in emitted + + +def test_integration_error_line_is_plain_text(): + ig = integration("import sys; sys.stderr.write('\\x1b[2J\\x1b[31mbad\\x07\\n'); sys.exit(2)") + ig.poll(40) + assert ig.result[2] == "exit 2: bad" + + +def test_integration_failure_keeps_the_last_good_rows_under_the_error(): + ig = integration("print('first')") + ig.poll(40) + ig.command = [sys.executable, "-c", "import sys; sys.exit('boom')"] + ig.poll(40) + _, at, err = ig.result + assert err == "exit 1: boom" + assert [r.plain for r in devdash.render_integration(ig, at)] == ["T fetched 0s ago", "⚠ exit 1: boom", "first"] + + +def test_integration_timeout_kills_the_whole_process_group(tmp_path): + pid_file = tmp_path / "child.pid" + ig = integration(spawner(pid_file), timeout=3) + ig.poll(40) + assert ig.result[2] == "timed out after 3s" + assert gone(wait_for_pid(pid_file)), "the command's child outlived the timeout" + + +def test_integration_unexpected_capture_error_still_kills_the_command(tmp_path, monkeypatch): + pid_file = tmp_path / "child.pid" + + def broken(p, deadline): + wait_for_pid(pid_file) + raise OSError("selector failed") + + monkeypatch.setattr(devdash, "capture", broken) + ig = integration(spawner(pid_file), timeout=60) + ig.poll(40) + assert ig.result[2] == "selector failed" + assert gone(int(pid_file.read_text())), "the command's child outlived the error" + + +def test_integration_output_is_cut_to_max_rows_with_a_count(): + rows = devdash.output_rows("".join(f"row {i}\n" for i in range(10)) + "\n\n", 4) + assert [r.plain for r in rows] == ["row 0", "row 1", "row 2", "… 7 more rows"] + + +def test_integration_flooding_stdout_is_stopped_at_the_output_cap(): + flood = [sys.executable, "-c", "import sys\nwhile True: sys.stdout.buffer.write(b'x' * 65536)"] + p = devdash.subprocess.Popen(flood, stdout=devdash.subprocess.PIPE, stderr=devdash.subprocess.PIPE, + start_new_session=True) + started = time.monotonic() + out, err, full = devdash.capture(p, started + 30) + assert full and len(out) == devdash.MAX_OUTPUT and err == b"" + assert p.returncode is not None and time.monotonic() - started < 10 # stopped, not left to time out + ig = integration("import sys\nwhile True: sys.stdout.write('row\\n' * 4096)", timeout=30, max_rows=3) + ig.poll(40) + rows, _, err = ig.result + assert err is None and [r.plain for r in rows][:2] == ["row", "row"] + + +def test_integration_keeps_only_the_tail_of_a_flooded_stderr(): + ig = integration("import sys\nfor _ in range(200): sys.stderr.write('noise ' * 10000 + '\\n')\n" + "sys.exit('the real reason')", timeout=30) + p = devdash.subprocess.Popen(ig.command, stdout=devdash.subprocess.PIPE, stderr=devdash.subprocess.PIPE) + _, err, _ = devdash.capture(p, time.monotonic() + 30) + assert len(err) <= devdash.ERR_TAIL and err.endswith(b"the real reason\n") + ig.poll(40) + assert ig.result[2] == "exit 1: the real reason" + + +def test_stopping_an_integration_kills_its_running_command_and_children(tmp_path): + pid_file = tmp_path / "child.pid" + ig = integration(spawner(pid_file), timeout=60) + thread = devdash.threading.Thread(target=ig.loop, args=(devdash.State(),), daemon=True) + thread.start() + try: + child = wait_for_pid(pid_file) + finally: + ig.stop() + thread.join(5) + assert not thread.is_alive() # the loop ends instead of waiting out the 60s timeout + assert gone(child), "the command's child outlived devdash" + + +def test_integrations_render_in_their_slots_between_built_in_sections(): + st = devdash.State() + st.integrations = [] + st.show_usage = st.show_review = True + st.usage, st.mine, st.review = {"reports": []}, [], [] + for name in ("bottom", "after-my-prs", "top", "after-usage", "bottom2"): + ig = integration("", name=name, position=name.rstrip("2")) + ig.result = ([devdash.line(f"{name} body")], 0, None) + st.integrations.append(ig) + console = Console(record=True, width=50) + console.print(devdash.Dashboard(st), height=10_000) + heads = [r for r in console.export_text().splitlines() if r.split(" ")[0].isupper() and r.strip()] + assert [h.split(" ")[0].strip() for h in heads] == [ + "TOP", "USAGE", "AFTER-USAGE", "MY PRS 0", "AFTER-MY-PRS", "REVIEW REQUESTED 0", "BOTTOM", "BOTTOM2"]