mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
test(tui): verify real terminal boundaries
This commit is contained in:
@@ -218,6 +218,11 @@ jobs:
|
|||||||
working-directory: tui
|
working-directory: tui
|
||||||
run: bun run test
|
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
|
- name: Build TUI
|
||||||
working-directory: tui
|
working-directory: tui
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|||||||
@@ -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())
|
||||||
+46
-3
@@ -179,15 +179,38 @@ describe("NanobotTui layout", () => {
|
|||||||
app.accept({
|
app.accept({
|
||||||
event: "delta",
|
event: "delta",
|
||||||
chat_id: "chat",
|
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" })
|
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)
|
setup.resize(width, height)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
const frame = setup.captureCharFrame()
|
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)
|
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")
|
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 () => {
|
test("keeps semantic colors legible in both terminal appearances", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
Reference in New Issue
Block a user