Skip to content
Merged
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
56 changes: 52 additions & 4 deletions scripts/tui_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ def assert_protocol_safety(raw):
assert_balanced_mode(changes, 2004, "bracketed-paste")


# The marker that separates rendered text from the style spans below it.
# scripts/tui_golden.sh splits captures on this string too.
STYLE_HEADER = "-- cell styles --"


def cell_style(cell):
"""Return a stable description of non-default pyte cell attributes."""
attributes = []
Expand Down Expand Up @@ -185,7 +190,7 @@ def render_snapshot(screen):

text = "\n".join(lines) + ("\n" if lines else "")
if spans:
text += "\n-- cell styles --\n" + "\n".join(spans) + "\n"
text += "\n" + STYLE_HEADER + "\n" + "\n".join(spans) + "\n"
return text


Expand All @@ -203,6 +208,50 @@ def assert_semantic_text(raw, expected_values, post_resize_raw=None):
)


def child_env(base=None):
"""Build the captured process's environment.

The harness owns a real PTY and has already set its size, so the child
is genuinely attached to a terminal and should render exactly as it
would for a developer. Anything in the host environment that would
argue otherwise is removed rather than trusted, because a golden
harness whose output depends on where it runs is not a golden harness.

NO_COLOR counts by presence, even when set to "0".

CI is the one that actually bit: termenv answers isTTY() with a flat
"no" whenever CI is set, before it ever looks at the file descriptor,
so lipgloss falls back to the Ascii profile and the render carries no
SGR at all. Every captured style span disappears and each golden looks
changed for no visible reason. CLICOLOR_FORCE is not a substitute: it
only lifts Ascii to 16-colour ANSI, while the goldens record TrueColor.
"""
env = dict(os.environ if base is None else base,
TERM="xterm-256color", COLORTERM="truecolor", CLICOLOR="1")
for name in ("NO_COLOR", "CI"):
env.pop(name, None)
return env


def assert_styled(cells, target, scenario):
"""Require at least one styled cell in a captured snapshot.

A capture that renders every cell in the default style is not a
snapshot of this TUI, it is a snapshot of colour having been switched
off -- which is what happens when something in the environment
convinces termenv it is not writing to a terminal. Without this check
the failure is silent in both directions: `check` reports every golden
as changed with no hint why, and `update` will happily overwrite the
reviewed goldens with colourless ones.
"""
if STYLE_HEADER not in cells:
raise RuntimeError(
f"{target}/{scenario}: capture contains no styled cells, so the "
"render was produced with colour disabled. Check that nothing in "
"the environment (CI, NO_COLOR, TERM) is suppressing it."
)


def capture(command, width, height, keys, settle, timeout, resize):
if os.name == "nt":
raise RuntimeError(
Expand All @@ -213,9 +262,7 @@ def capture(command, width, height, keys, settle, timeout, resize):
master, slave = pty.openpty()
# TIOCSWINSZ takes rows, columns.
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", height, width, 0, 0))
env = dict(os.environ, TERM="xterm-256color", COLORTERM="truecolor", CLICOLOR="1")
# NO_COLOR is enabled by presence, even when its value is "0".
env.pop("NO_COLOR", None)
env = child_env()
proc = subprocess.Popen(command, stdin=slave, stdout=slave, stderr=slave,
env=env, start_new_session=True, close_fds=True)
os.close(slave)
Expand Down Expand Up @@ -308,6 +355,7 @@ def main():
if args.protocol_check:
assert_protocol_safety(raw)
assert_semantic_text(raw, args.expect_text, post_resize_raw)
assert_styled(cells, args.target, args.scenario)
geometry = f"{initial_width}x{initial_height}"
if args.resize is not None:
geometry += f"-to-{width}x{height}"
Expand Down
33 changes: 33 additions & 0 deletions scripts/tui_capture_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,38 @@ def test_non_default_cell_styles_are_serialized(self):
self.assertIn("bold", snapshot)


class ChildEnvTests(unittest.TestCase):
def test_ci_is_removed_so_termenv_sees_a_terminal(self):
# termenv reports "not a TTY" whenever CI is set, whatever the file
# descriptor actually is, which strips every style span from the
# capture and makes every golden look changed.
env = tui_capture.child_env({"CI": "true", "PATH": "/usr/bin"})
self.assertNotIn("CI", env)
self.assertEqual(env["PATH"], "/usr/bin")

def test_no_color_is_removed_even_when_zero(self):
env = tui_capture.child_env({"NO_COLOR": "0"})
self.assertNotIn("NO_COLOR", env)

def test_colour_capability_is_pinned(self):
env = tui_capture.child_env({"TERM": "dumb", "COLORTERM": ""})
self.assertEqual(env["TERM"], "xterm-256color")
self.assertEqual(env["COLORTERM"], "truecolor")
self.assertEqual(env["CLICOLOR"], "1")


class AssertStyledTests(unittest.TestCase):
def test_styled_capture_is_accepted(self):
snapshot = "hi" + chr(10) + chr(10) + "-- cell styles --" + chr(10) + "1:1-2 fg=010203" + chr(10)
tui_capture.assert_styled(snapshot, "packetcode", "plan")

def test_styleless_capture_is_rejected(self):
# Colour switched off yields text and no spans. Without this guard
# `update` would promote that into the reviewed goldens.
with self.assertRaises(RuntimeError) as caught:
tui_capture.assert_styled("hi" + chr(10), "packetcode", "plan")
self.assertIn("packetcode/plan", str(caught.exception))
self.assertIn("colour disabled", str(caught.exception))

if __name__ == "__main__":
unittest.main()
Loading