diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1098845f2..fe682bfd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,11 @@ jobs: working-directory: tui run: bun run test + - name: Test TUI in a real pseudo-terminal + if: runner.os != 'Windows' + working-directory: tui + run: python3 scripts/pty_smoke.py + - name: Build TUI working-directory: tui run: bun run build diff --git a/tui/scripts/pty_smoke.py b/tui/scripts/pty_smoke.py new file mode 100644 index 000000000..32dfca73e --- /dev/null +++ b/tui/scripts/pty_smoke.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Exercise the TUI through a real POSIX pseudo-terminal. + +OpenTUI's test renderer proves retained-layout semantics. This smoke test covers +the OS boundary it cannot: raw Unicode input, SIGWINCH-driven resize, and +alternate-screen restoration after Ctrl+C. +""" + +from __future__ import annotations + +import errno +import fcntl +import os +import pty +import select +import shutil +import signal +import struct +import sys +import termios +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ENTER_ALT_SCREEN = b"\x1b[?1049h" +LEAVE_ALT_SCREEN = b"\x1b[?1049l" + + +def _resize(fd: int, rows: int, columns: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + + +def _read(fd: int, timeout: float) -> bytes: + output = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + readable, _, _ = select.select([fd], [], [], min(0.05, deadline - time.monotonic())) + if not readable: + continue + try: + chunk = os.read(fd, 65_536) + except OSError as exc: + if exc.errno == errno.EIO: # PTY slave closed. + break + raise + if not chunk: + break + output.extend(chunk) + return bytes(output) + + +def _wait_for(fd: int, needle: bytes, timeout: float) -> bytes: + output = bytearray() + deadline = time.monotonic() + timeout + while needle not in output and time.monotonic() < deadline: + output.extend(_read(fd, min(0.1, deadline - time.monotonic()))) + if needle not in output: + raise AssertionError(f"terminal output did not contain {needle!r}") + return bytes(output) + + +def _wait_for_exit(pid: int, timeout: float) -> int: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + child, status = os.waitpid(pid, os.WNOHANG) + if child: + return os.waitstatus_to_exitcode(status) + time.sleep(0.05) + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + raise AssertionError("TUI did not exit after Ctrl+C") + + +def main() -> int: + if os.name != "posix": + raise SystemExit("PTY smoke test requires POSIX") + bun = shutil.which("bun") + if not bun: + raise SystemExit("bun is required") + env = { + **os.environ, + "NANOBOT_TUI_WS_URL": "ws://127.0.0.1:9/ws", + "NANOBOT_TUI_API_URL": "", + "NANOBOT_TUI_API_TOKEN": "", + "NANOBOT_TUI_MODEL": "test/model", + "NANOBOT_TUI_WORKSPACE": "/tmp/nanobot-tui-pty", + "NANOBOT_TUI_VERSION": "test", + "NANOBOT_TUI_ACCESS": "workspace access", + # A fixed theme keeps this test about PTY behavior rather than the + # terminal emulator's optional OSC 10/11 response. + "NANOBOT_TUI_THEME": "dark", + } + + pid, master = pty.fork() + if pid == 0: + os.chdir(ROOT) + os.execvpe(bun, [bun, "src/index.ts"], env) + + output = bytearray() + reaped = False + try: + _resize(master, 24, 80) + output.extend(_wait_for(master, ENTER_ALT_SCREEN, 10)) + + # Feed committed UTF-8 one grapheme at a time. The raw terminal stream + # should contain every glyph, even though differential rendering may + # place escape sequences between adjacent characters. + for grapheme in "abc中文🙂": + os.write(master, grapheme.encode()) + time.sleep(0.03) + output.extend(_read(master, 0.5)) + + _resize(master, 18, 42) + os.kill(pid, signal.SIGWINCH) + resized = _read(master, 0.5) + output.extend(resized) + if b"\x1b[18;" not in resized or b";42H" not in resized: + raise AssertionError("TUI did not repaint to the resized PTY dimensions") + + # First Ctrl+C clears the draft; the second exits and must restore the + # alternate screen without a prompt_toolkit-style traceback. + os.write(master, b"\x03") + output.extend(_read(master, 0.2)) + os.write(master, b"\x03") + output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5)) + exit_code = _wait_for_exit(pid, 5) + reaped = True + finally: + if not reaped: + try: + child, _ = os.waitpid(pid, os.WNOHANG) + if not child: + os.kill(pid, signal.SIGKILL) + os.waitpid(pid, 0) + except ChildProcessError: + pass + os.close(master) + + text = output.decode("utf-8", errors="replace") + if exit_code != 0: + raise AssertionError(f"TUI exited with status {exit_code}") + for glyph in "abc中文🙂": + if glyph not in text: + raise AssertionError(f"TUI did not render committed input {glyph!r}") + if "Traceback" in text or "Task exception was never retrieved" in text: + raise AssertionError("TUI emitted an exception during shutdown") + print("PTY smoke test passed: Unicode input, resize, and terminal restoration") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index b9ef31524..4972d7c25 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -179,15 +179,38 @@ describe("NanobotTui layout", () => { app.accept({ event: "delta", chat_id: "chat", - text: "中文会随着终端宽度重新排版。\n\n```ts\nconst greeting = '你好,nanobot'\n```", + text: [ + "中文、emoji 👨‍💻 and combining text é reflow with the terminal.", + "https://nanobot.test/a-very-long-unbroken-path-that-must-not-break-the-layout", + "```ts", + "const greeting = '你好,nanobot'", + "```", + ].join("\n\n"), }) app.accept({ event: "stream_end", chat_id: "chat" }) - for (const [width, height] of [[42, 12], [30, 9], [84, 24], [48, 14], [110, 32]] as const) { + for (const [width, height] of [ + [240, 80], + [42, 12], + [30, 9], + [20, 6], + [12, 4], + [8, 3], + [4, 2], + [84, 24], + [48, 14], + [110, 32], + ] as const) { setup.resize(width, height) await setup.renderOnce() const frame = setup.captureCharFrame() - expect(occurrences(frame, "Ask nanobot anything")).toBe(1) + expect(setup.renderer.width).toBe(width) + expect(setup.renderer.height).toBe(height) + expect(frame).not.toContain("undefined") + expect(occurrences(frame, "Ask nanobot anything")).toBeLessThanOrEqual(1) + if (width >= 30 && height >= 9) { + expect(occurrences(frame, "Ask nanobot anything")).toBe(1) + } expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 12 ? 1 : 0) } }) @@ -276,6 +299,26 @@ describe("NanobotTui layout", () => { expect((app as unknown as { palette: { background: string } }).palette.background).toBe("#FAFAFA") }) + test("falls back to the dark palette when terminal theme probing has no answer", async () => { + setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) + let connected = false + setup.renderer.waitForThemeMode = async () => null + Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: null }) + const transport = client() + transport.connect = () => { connected = true } + const app = NanobotTui.mount( + setup.renderer, + options, + transport, + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + + await app.start() + + expect(connected).toBe(true) + expect((app as unknown as { palette: { background: string } }).palette.background).toBe("#0E0F11") + }) + test("keeps semantic colors legible in both terminal appearances", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup)