mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
test(tui): exercise Windows ConPTY boundary
This commit is contained in:
@@ -223,6 +223,20 @@ jobs:
|
||||
working-directory: tui
|
||||
run: python3 scripts/pty_smoke.py
|
||||
|
||||
- name: Set up Python for ConPTY smoke test
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Test TUI in a real ConPTY terminal
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: tui
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m pip install --disable-pip-version-check pywinpty==3.0.5
|
||||
python scripts/conpty_smoke.py
|
||||
|
||||
- name: Build TUI
|
||||
working-directory: tui
|
||||
run: bun run build
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the TUI through the native Windows ConPTY boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENTER_ALT_SCREEN = "\x1b[?1049h"
|
||||
LEAVE_ALT_SCREEN = "\x1b[?1049l"
|
||||
|
||||
|
||||
def _read(process: Any, timeout: float) -> str:
|
||||
output: list[str] = []
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
chunk = process.read(65_536)
|
||||
except EOFError:
|
||||
break
|
||||
if chunk:
|
||||
output.append(chunk)
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def _wait_for(process: Any, needle: str, timeout: float) -> str:
|
||||
output: list[str] = []
|
||||
deadline = time.monotonic() + timeout
|
||||
while needle not in "".join(output) and time.monotonic() < deadline:
|
||||
output.append(_read(process, min(0.1, deadline - time.monotonic())))
|
||||
text = "".join(output)
|
||||
if needle not in text:
|
||||
raise AssertionError(f"terminal output did not contain {needle!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _wait_for_exit(process: Any, timeout: float) -> int:
|
||||
deadline = time.monotonic() + timeout
|
||||
while process.isalive() and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if process.isalive():
|
||||
process.close(force=True)
|
||||
raise AssertionError("TUI did not exit after Ctrl+C")
|
||||
return int(process.exitstatus or 0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if os.name != "nt":
|
||||
raise SystemExit("ConPTY smoke test requires Windows")
|
||||
|
||||
# PyWinPTY's high-level reader otherwise blocks when ConPTY has no new
|
||||
# bytes, preventing bounded CI assertions.
|
||||
os.environ["PYWINPTY_BLOCK"] = "0"
|
||||
from winpty import PtyProcess # type: ignore[import-not-found]
|
||||
from winpty.enums import Backend # type: ignore[import-not-found]
|
||||
|
||||
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": r"C:\nanobot-tui-conpty",
|
||||
"NANOBOT_TUI_VERSION": "test",
|
||||
"NANOBOT_TUI_ACCESS": "workspace access",
|
||||
"NANOBOT_TUI_THEME": "dark",
|
||||
# Headless ConPTY has no terminal emulator to answer optional OSC 66
|
||||
# width probes. Keep this smoke test focused on application behavior.
|
||||
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
|
||||
}
|
||||
process = PtyProcess.spawn(
|
||||
[bun, "src/index.ts"],
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
dimensions=(24, 80),
|
||||
backend=Backend.ConPTY,
|
||||
)
|
||||
|
||||
output: list[str] = []
|
||||
try:
|
||||
output.append(_wait_for(process, ENTER_ALT_SCREEN, 15))
|
||||
for grapheme in "abc中文🙂":
|
||||
process.write(grapheme)
|
||||
time.sleep(0.03)
|
||||
output.append(_read(process, 0.5))
|
||||
|
||||
process.setwinsize(18, 42)
|
||||
resized = _read(process, 0.75)
|
||||
output.append(resized)
|
||||
if "\x1b[18;" not in resized or ";42H" not in resized:
|
||||
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
|
||||
|
||||
# First Ctrl+C clears the draft. The second exits the app and must
|
||||
# restore the alternate screen without an unhandled exception.
|
||||
process.sendcontrol("c")
|
||||
output.append(_read(process, 0.2))
|
||||
process.sendcontrol("c")
|
||||
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
|
||||
exit_code = _wait_for_exit(process, 8)
|
||||
finally:
|
||||
if process.isalive():
|
||||
process.close(force=True)
|
||||
|
||||
text = "".join(output)
|
||||
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("ConPTY smoke test passed: Unicode input, resize, and terminal restoration")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user