diff options
| author | user <user@clank> | 2026-08-15 19:14:59 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 19:14:59 +0200 |
| commit | 2f0b3426d0f40d2cf53740cb597495af2c45bc2e (patch) | |
| tree | 1969c511f2ec69e234e4eb8b2ba1aa2bf78c7904 /tests | |
| parent | sl0ppty: measure chrome in columns, not characters (diff) | |
| download | slosh-2f0b3426d0f40d2cf53740cb597495af2c45bc2e.tar.gz slosh-2f0b3426d0f40d2cf53740cb597495af2c45bc2e.tar.xz slosh-2f0b3426d0f40d2cf53740cb597495af2c45bc2e.zip | |
sl0ppty: decode the PNGs programs actually send
Kitty graphics were green in the suite and a real program still drew
nothing. libghostty-vt ships no image codec: built as a library it sets
its `sys.decode_png` hook to null, and a null hook rejects every `f=100`
transmission outright -- no image stored, so no placement exists, so the
re-emission layer this project is mostly *about* is never reached. The
program gets no error and no picture.
Raw RGB worked throughout, which is why it looked done: every test here
sent raw pixels, because that is what is easy to write in a test. It is
the same lesson the last kitty graphics bug taught -- write the test the
way a program actually behaves -- and a program with a picture on disk
has a PNG. Found because ida-tui's startup logo never appeared, and its
detection probe answers `OK` first, so it fails silently rather than
falling back to its block art.
PNG is the whole of the gap and cannot grow: the protocol has exactly
three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels
lib-vt already handles, and `decode_png` is the only codec hook it has.
Decoding only, never encoding.
The decoder is the stb_image lib-vt already vendors and we already
commit -- upstream compiles that same header with the same
`STBI_ONLY_PNG` for this same purpose, it is just not in the
`-Demit-lib-vt` build. So nothing new is pulled in; a file we already
ship is finally compiled. src/png.c is the hook, a header pre-check and
a copy: dimensions are read before decoding because the buffer we return
is allocated through lib-vt's limited allocator but stb's own working
allocation is plain malloc and answers to nothing.
Tests cover a PNG placed at the size it asked for (RGB and RGBA sources,
both normalised to RGBA), the client being handed `f=32` rather than the
png it was given, and a malformed png placing nothing without taking the
pane with it. They fail on the previous binary.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_graphics.py | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/tests/test_graphics.py b/tests/test_graphics.py index 241498e..3ead630 100644 --- a/tests/test_graphics.py +++ b/tests/test_graphics.py @@ -7,7 +7,9 @@ cannot collide between panes. That collision is the whole reason tmux and zellij drop images instead, and it is the first thing tested here. """ import base64 +import struct import sys +import zlib from harness import Session, check, report @@ -15,6 +17,24 @@ from harness import Session, check, report PX = base64.b64encode(bytes([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0] * 2)).decode() +def png(w=4, h=2, color_type=2): + """A minimal PNG, built here so the suite needs no binary fixtures. + + `color_type` 2 is RGB and 6 is RGBA; both are worth sending, because the + decoder is asked to normalise whatever the file is into RGBA. + """ + def chunk(tag, body): + c = tag + body + return struct.pack(">I", len(body)) + c + struct.pack(">I", zlib.crc32(c)) + + px = [255, 0, 0] if color_type == 2 else [255, 0, 0, 255] + raw = b"".join(b"\x00" + bytes(px * w) for _ in range(h)) + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, color_type, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"")) + + def sends_image(image_id=7, cols=6, rows=2, after="sleep 5"): seq = f"\\033_Ga=T,f=24,s=4,v=2,i={image_id},q=2,c={cols},r={rows};{PX}\\033\\\\" return ["/bin/sh", "-c", f'stty raw -echo; printf "{seq}"; {after}'] @@ -287,6 +307,68 @@ def test_partial_visibility_crops_rather_than_squashes(): f"{pl[0]} vs {p['content_h']}") +def sends_png(data, place="c=6,r=2", image_id=7): + """Upload with `a=t,f=100` and place it: what a program with a picture on + disk does, and what ida-tui's splash does. + + The child is python rather than sh because `printf` mangles adjacent + backslash escapes -- `\\033\\\\` followed by `\\033` arrives as a literal + `\\033` -- so a shell-built sequence does not reliably reach the pty as the + bytes you wrote. That cost an afternoon and a wrong diagnosis. + """ + b64 = base64.b64encode(data).decode() + prog = ("import sys, time\n" + f"sys.stdout.write('\\033_Ga=t,f=100,t=d,i={image_id},q=2;{b64}\\033\\\\')\n" + f"sys.stdout.write('\\033_Ga=p,i={image_id},p=1,{place},q=2\\033\\\\')\n" + "sys.stdout.write('DONE')\n" + "sys.stdout.flush()\n" + "time.sleep(5)\n") + return ["python3", "-c", prog] + + +def test_a_png_is_decoded_and_placed(): + """libghostty-vt ships no image codec: built as a library its `decode_png` + hook is null, and a null hook rejects every `f=100` transmission outright. + No image was stored, so no placement existed and there was nothing to + re-emit -- a program that uploaded a PNG drew nothing at all, silently, + while raw RGB worked. Found because ida-tui's startup logo never appeared.""" + for color_type, what in ((2, "RGB"), (6, "RGBA")): + with Session(sends_png(png(color_type=color_type)), cols=44, rows=10) as s: + s.until_text("DONE") + s.settle(200) + pl = places(s) + check(f"a {what} png is placed", len(pl) == 1, str(pl)) + if pl: + check("at the size it asked for", + (pl[0]["cols"], pl[0]["rows"]) == (6, 2), str(pl[0])) + + +def test_a_decoded_png_reaches_the_client_as_raw_pixels(): + """The client is told `f=32`: decoding is the point, and the terminal we + are re-emitting to should not have to decode it a second time.""" + with Session(sends_png(png()), cols=44, rows=10) as s: + s.until_text("DONE") + s.settle(200) + raw = s.api("graphics", format="bytes")["bytes"] + tx = [c for c in raw.split("\x1b") if c.startswith("_Ga=t")] + check("the image is transmitted to the client", tx != [], repr(raw[:120])) + if tx: + check("as RGBA, not as the png we were given", + "f=32" in tx[0] and "f=100" not in tx[0], tx[0][:80]) + + +def test_a_png_that_is_not_one_places_nothing(): + """The bytes come off a pane's pty, so the decoder is fed whatever a + program feels like sending. Refusing it is all that is asked; the pane + must still be there afterwards.""" + junk = b"\x89PNG\r\n\x1a\n" + b"\xde\xad\xbe\xef" * 32 + with Session(sends_png(junk), cols=44, rows=10) as s: + s.until_text("DONE") + s.settle(200) + check("nothing is placed", places(s) == [], str(places(s))) + check("and the session survived it", s.pane() is not None) + + def test_scrolled_away_placements_are_dropped(): prog = ("stty raw -echo; " f'printf "\\033_Ga=T,f=24,s=4,v=2,i=7,q=2,c=6,r=2;{PX}\\033\\\\"; ' @@ -299,6 +381,9 @@ def test_scrolled_away_placements_are_dropped(): if __name__ == "__main__": test_a_pane_image_reaches_the_screen() + test_a_png_is_decoded_and_placed() + test_a_decoded_png_reaches_the_client_as_raw_pixels() + test_a_png_that_is_not_one_places_nothing() test_a_placement_that_does_not_say_how_big_it_is() test_the_cell_size_a_client_reports_is_what_sizes_an_image() test_a_program_can_read_the_pixel_size_from_its_pty() |
