Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions packaging/timbergraph.1
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,38 @@ where the name resolves to a different document or to none.
.B \-\-list
Say what the input holds and draw nothing.
.TP
.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window
Draw somewhere other than the terminal.
.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window ", " \-\-ascii
WHERE to draw, and by DEFAULT that is a WINDOW \(em characters cannot
carry the detail, five series an order of magnitude apart collapsing into
two rows of them with one disappearing altogether. It needs three things
and none implies another: a display
.RB ( $DISPLAY
or
.BR $WAYLAND_DISPLAY ),
a
.BR gnuplot (1)
built with an interactive terminal
.RB ( gnuplot\-nox
has none), and a TTY to be watching it \(em so a redirected or scripted
run keeps its answer as text rather than losing it to a window nobody
sees. Missing any of them, the default is ASCII.
.IP
.B \-\-window
and
.B \-\-ascii
say which outright, and
.B \-\-window
needs a display and gnuplot's qt or x11 terminal.
overrides the TTY test, a window not being stdout. A
.B \-\-window
that cannot be drawn is REFUSED rather than downgraded, because the point
of naming it is to get one.
.BI \-\-png " FILE"
and
.BI \-\-svg " FILE"
write a file. One of the four at a time: two name different places to
draw. ⚠ Identical in
.BR timbersh (1),
which calls this same rule rather than keeping its own.
.TP
.BI \-\-width " N" ", " \-\-height " N"
The plot's size \(em CHARACTERS on the terminal, where it defaults to the
Expand Down
30 changes: 30 additions & 0 deletions packaging/timbersh.1
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,36 @@ and
.BR timbergraph (1)
is the same drawing outside the shell.
.PP
WHERE IT DRAWS: a WINDOW where one can be drawn, and characters on the
terminal where it cannot \(em so the same statement gives a picture at a
desk and still answers over ssh. A window wants two things, and neither
implies the other: a display
.RB ( $DISPLAY
or
.BR $WAYLAND_DISPLAY )
and a
.BR gnuplot (1)
built with an interactive terminal, which
.B gnuplot\-nox
is not \(em and, for the DEFAULT only, a terminal to be watching it: a
redirected run keeps its answer as text rather than losing it to a
window nobody asked for. Naming
.B window
overrides that, a window not being stdout.
.B window
and
.B ascii
say which outright, and a
.B window
that cannot be drawn is REFUSED rather than quietly downgraded \(em the
point of naming it is to get one.
.BI "png " \(fmFILE\(fm
and
.BI "svg " \(fmFILE\(fm
write a file instead, which timbersh does not open for you. One of the
four at a time: two name different places to draw, and a statement whose
destination is picked for it means something other than what it says.
.PP
.B view
opens a store as a tape rather than as an answer, IN PROCESS: the loop it
exists for is search, jump, search again, and a separate program would
Expand Down
56 changes: 56 additions & 0 deletions tests/timbergraph/test-timbergraph
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,62 @@ def raises(name, fn, contains):
FAILED.append(f"{name}: no refusal, and {contains!r} was expected")


def test_where_it_draws_is_one_rule_for_both_tools():
"""`choose_terminal` is the whole of it, and timbersh calls this same
function — two copies would drift and the same words would then mean
different pictures depending which you typed them in.

The default is a window because ASCII cannot carry the detail. It
wants a display, an interactive gnuplot terminal AND a tty, and the
tty binds the default only: a redirected run keeps its answer as
text, while a NAMED window is what was asked for and a window is not
stdout.
"""
at_a_desk = dict(term="qt", tty=True)
redirected = dict(term="qt", tty=False)
no_window = dict(term=None, tty=True)
check("default at a desk", "qt", g.choose_terminal(**at_a_desk))
check("default redirected", "dumb", g.choose_terminal(**redirected))
check("no interactive terminal", "dumb", g.choose_terminal(**no_window))
check("named window survives a pipe", "qt",
g.choose_terminal(window=True, **redirected))
check("named ascii at a desk", "dumb",
g.choose_terminal(want_ascii=True, **at_a_desk))
check("a file needs no window", "pngcairo",
g.choose_terminal(png="f.png", **no_window))
check("svg likewise", "svg", g.choose_terminal(svg="f.svg", **no_window))


def test_a_window_that_cannot_be_drawn_is_refused_not_downgraded():
"""Naming it is asking for it, so ASCII instead would answer a
question nobody asked. The message has to name BOTH things that
could be missing — a display and a gnuplot built for one — because
either alone looks the same from here."""
raises("no terminal", lambda: g.choose_terminal(window=True, term=None,
tty=True),
"no interactive gnuplot terminal")
raises("names gnuplot-nox", lambda: g.choose_terminal(window=True,
term=None, tty=True),
"gnuplot-nox")


def test_two_places_to_draw_is_refused_rather_than_one_winning():
"""`--window --png f` used to write the file and no window; `--png`
beside `--svg` quietly preferred the png. A statement whose
destination is picked for it means something other than what it
says, so both are named and neither wins."""
raises("window and ascii",
lambda: g.choose_terminal(window=True, want_ascii=True, term="qt"),
"different places to draw")
raises("png and svg",
lambda: g.choose_terminal(png="a.png", svg="b.svg", term="qt"),
"different places to draw")
raises("names the flags it was given",
lambda: g.choose_terminal(window=True, png="a.png", term="qt",
spell="--"),
"`--window` and `--png`")


def test_a_line_says_what_it_holds():
s = g.parse_line(
'2026-09-06T13:37:00.000Z 60s m a=1 msg="two words" count=3 sum=1.5 @40+9'
Expand Down
25 changes: 25 additions & 0 deletions tests/timbersh/fake-gnuplot
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
# A gnuplot that exists but draws nothing, so a test can decide what
# this machine can do rather than inherit it.
#
# $FAKE_TERMS is the terminal list `set term` reports (space separated).
# It prints which of the two it was asked for: -p — which timbergraph
# passes only for an interactive terminal — is a WINDOW, anything else is
# text on stdout. So a test asserts what WAS chosen rather than the
# absence of the other, which would also pass if nothing ran at all.
case "$1" in
-e) [ "$2" = "set term" ] && {
echo "" >&2
echo "Available terminal types:" >&2
for t in ${FAKE_TERMS:-dumb}; do echo " $t a fake" >&2; done
}
# ⚠ Then READ STDIN to EOF, because that is what gnuplot does: -e
# runs the command and carries on reading. A fake that exited here
# would probe instantly on a terminal, where the real one waits for
# input — and the probe running without stdin redirected is exactly
# the defect this fidelity exists to catch.
cat >/dev/null
exit 0 ;;
-p) cat >/dev/null; echo "FAKE-GNUPLOT-WINDOW"; exit 0 ;;
*) cat >/dev/null; echo "FAKE-GNUPLOT-TEXT"; exit 0 ;;
esac
150 changes: 147 additions & 3 deletions tests/timbersh/test-timbersh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ ID_B = "bbbbbbbb-1111-2222-3333-444444444444"
class Session:
"""One timbersh run against a scripted server."""

def __init__(self, scenario, hosts=None, extra=(), resolver=False):
def __init__(self, scenario, hosts=None, extra=(), resolver=False,
env=None):
# `env` overrides the inherited environment per key; None as a
# value UNSETS one. Which is what lets a test say whether a
# display exists — `graph` draws a window where one can be drawn,
# so a suite that inherited the developer's DISPLAY would assert
# different things on a desk and in CI.
self.env = env or {}
self.dir = tempfile.mkdtemp(prefix="timbersh-test-")
json.dump(scenario, open(os.path.join(self.dir, "scenario.json"), "w"))
self.hosts = hosts
Expand Down Expand Up @@ -90,7 +97,9 @@ class Session:
try:
p = subprocess.run(cmd, input="\n".join(statements) + "\n\\q\n",
capture_output=True, text=True, timeout=60,
env={**os.environ, "FAKE_DIR": self.dir})
env={k: v for k, v in
{**os.environ, "FAKE_DIR": self.dir,
**self.env}.items() if v is not None})
except subprocess.TimeoutExpired as e:
# `text=True` does not reach here: what was captured before the
# timeout comes back as bytes.
Expand Down Expand Up @@ -166,7 +175,9 @@ class Pty(Session):
"--histfile", os.path.join(self.dir, "history"),
"--rc", os.path.join(self.dir, "nonexistent-rc")]
cmd += self.where() + self.extra
env = {**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm"}
env = {k: v for k, v in
{**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm",
**self.env}.items() if v is not None}
self.pid, self.fd = pty.fork()
if self.pid == 0:
os.execve(cmd[0], cmd, env)
Expand Down Expand Up @@ -457,6 +468,139 @@ def a_graph_makes_the_fleet_a_dimension_rather_than_merging_it():
s.cleanup()


def with_gnuplot(terms):
"""A PATH whose `gnuplot` is the fake, reporting `terms` as the
terminals it has. Which is the only way to test the choice: a real
window needs a real display, so CI would assert the opposite of a
desk and neither would be testing the decision.

⚠ A DIRECTORY of one binary, prepended — not the whole of tests/,
which holds `fake-timberfs` and would shadow the real one.
"""
d = tempfile.mkdtemp(prefix="timbersh-gnuplot-")
shutil.copy(os.path.join(HERE, "fake-gnuplot"),
os.path.join(d, "gnuplot"))
return {"PATH": d + os.pathsep + os.environ["PATH"],
"FAKE_TERMS": terms}


@test
def a_graph_draws_a_window_by_default_where_one_can_be_drawn():
"""The default is a window where one can be drawn, and that decision
is what this pins: a display, a gnuplot reporting an interactive
terminal, and somebody watching a terminal. The fake prints its
marker only when asked for one with `-p`, so this fails if `dumb`
was chosen.

⚠ On a PTY, because the default is gated on stdout being one — over
a pipe the answer is the text and a window would steal it."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
s = Pty({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env={**with_gnuplot("qt dumb"), "DISPLAY": ":99"}).start()
s.send("graph heap from [class=tally];\n")
s.expect("FAKE-GNUPLOT-WINDOW")
s.finish().cleanup()


@test
def a_redirected_run_keeps_its_answer_rather_than_losing_it_to_a_window():
"""The suite itself is the case: stdout is a pipe. A DEFAULT window
there would turn `timbersh -q < script > out.txt` at a desk into an
empty file, so the default is a window only where somebody is
watching one. Naming `window` still gets one -- a window is not
stdout, and asking for it with the output redirected is coherent."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
env = {**with_gnuplot("qt dumb"), "DISPLAY": ":99"}
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env=env).run("graph heap from [class=tally];")
assert "FAKE-GNUPLOT-TEXT" in s.out, s.out
s.cleanup()
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env=env).run("graph heap from [class=tally] window;")
assert "FAKE-GNUPLOT-WINDOW" in s.out, s.out
s.cleanup()


@test
def a_gnuplot_with_no_interactive_terminal_draws_ascii_though_a_display_exists():
"""`gnuplot-nox` is the case a DISPLAY check alone gets wrong: the
display is there and the terminal is not, so a window cannot be
drawn and asking for one is an error rather than a picture."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
env = {**with_gnuplot("dumb svg pngcairo"), "DISPLAY": ":99"}
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env=env).run("graph heap from [class=tally];")
assert "FAKE-GNUPLOT-TEXT" in s.out, s.out
s.cleanup()
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env=env).run("graph heap from [class=tally] window;")
assert "no interactive gnuplot terminal" in s.out, s.out
s.cleanup()


@test
def a_graph_draws_ascii_where_no_window_can_be_drawn():
"""The default is a WINDOW where one can be drawn and ASCII where it
cannot, so a session over ssh still answers. With no display there is
nothing to put a window on, whatever gnuplot can do."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run(
"graph heap from [class=tally];")
# The dumb terminal draws with characters; a window writes nothing to
# stdout at all.
assert "heap" in s.out, s.out
assert "no interactive gnuplot terminal" not in s.out, s.out
s.cleanup()


@test
def a_named_window_that_cannot_be_drawn_is_refused_not_downgraded():
"""Saying `window` is asking for one. Falling back to ASCII there
would answer a question nobody asked and hide the reason — the two
things needed are a display and a gnuplot built with an interactive
terminal, and the message has to name both."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run(
"graph heap from [class=tally] window;")
assert "no interactive gnuplot terminal" in s.out, s.out
assert "gnuplot-nox" in s.out, s.out
s.cleanup()


@test
def two_places_to_draw_is_refused_rather_than_one_of_them_winning():
"""`window png 'f'` named a file and a window; the file won and the
window was dropped in silence. Two destinations is a statement that
means something other than what it says, so it is refused and both
are named."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
for stmt, want in (("window ascii", ("window", "ascii")),
("window png 'f.png'", ("window", "png")),
("png 'f.png' svg 'f.svg'", ("png", "svg"))):
s = Session({"": {"stores": [store("t", ID_A)],
"loglines": gauge}}).run(
f"graph heap from [class=tally] {stmt};")
assert "different places to draw" in s.out, (stmt, s.out)
for w in want:
assert f"`{w}`" in s.out, (stmt, w, s.out)
s.cleanup()


@test
def ascii_is_sayable_where_a_window_would_otherwise_be_drawn():
"""The inverse of the default: at a desk, one graph wanted as text
to paste somewhere. It must not depend on unsetting a variable."""
gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n"
s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}},
env={"DISPLAY": ":99"}).run(
"graph heap from [class=tally] ascii;")
assert "heap" in s.out, s.out
assert "different places" not in s.out, s.out
s.cleanup()


@test
def a_graph_refuses_a_host_label_it_would_have_to_write_over():
"""The shell adds `host` itself, so a metric that already carries one
Expand Down
Loading
Loading