"""Ctrl-G must lend the editor a real terminal, then restore the composer.""" import os import pty import select import sys import termios import time import unittest from harness import Albedo, exclusive EDITOR = r""" import json import os from pathlib import Path import sys import termios import tty if not all(os.isatty(fd) for fd in (0, 1, 2)): print("EDITOR_NOT_A_TERMINAL", flush=True) sys.exit(2) path = Path(sys.argv[1]) state = termios.tcgetattr(0) try: tty.setraw(0) rows, columns = os.get_terminal_size(1) # Exercise a full-screen render, not just a short line in a pipe. os.write(1, b"\x1b[?1049h\x1b[H" + (b"x" * columns) * rows) os.write(1, b"EDITOR_READY:" + json.dumps(path.read_text()).encode()) data = b"" while not data.endswith(b"\r"): data += os.read(0, 1) if data == b"fail\r": sys.exit(7) path.write_text(data[:-1].decode().replace("\x1b", "")) finally: os.write(1, b"\x1b[?1049lEDITOR_DONE") termios.tcsetattr(0, termios.TCSANOW, state) """ @exclusive # The CLI's EDITOR and TERM are daemon-fixture environment settings. class EditorTest(unittest.TestCase): def test_editor_owns_terminal_and_composer_recovers_after_success_and_failure(self): def prepare(app): editor = app.root / "editor.py" editor.write_text(EDITOR) app.env.update(EDITOR=f"{sys.executable} {editor}", TERM="xterm-256color") with Albedo(prepare=prepare) as app: session = app.session() master, slave = pty.openpty() termios.tcsetwinsize(slave, (24, 100)) process = app.daemon.start_cli( "resume", session, stdin=slave, stdout=slave, stderr=slave ) os.close(slave) output = b"" def until(marker): nonlocal output deadline = time.monotonic() + 15 while marker not in output: remaining = deadline - time.monotonic() self.assertGreater(remaining, 0, repr(output[-4000:])) readable, _, _ = select.select([master], [], [], remaining) if not readable: continue chunk = os.read(master, 65536) self.assertTrue(chunk, repr(output[-4000:])) output += chunk self.assertNotIn(b"EDITOR_NOT_A_TERMINAL", output) # Answer the startup queries as an ordinary terminal would. if b"\x1b[6n" in chunk: os.write(master, b"\x1b[1;1R") if b"\x1b[c" in chunk: os.write(master, b"\x1b[?1;2c") if b"Restart Albedo? [y/N]" in chunk: os.write(master, b"n\r") if b"Press Enter to continue." in chunk: os.write(master, b"\r") try: until(b"your messages") os.write(master, b"draft\x07") until(b'EDITOR_READY:"draft"') output = b"" # Escape and navigation bytes must reach the editor unchanged. os.write(master, b"edited \x1b[Dprompt\r") until(b"EDITOR_DONE") until(b"your messages") output = b"" os.write(master, b"\x07") until(b'EDITOR_READY:"edited [Dprompt"') output = b"" os.write(master, b"fail\r") until(b"Could not finish editing the prompt") until(b"your messages") output = b"" # A failed editor must retain the previous draft. os.write(master, b"\x07") until(b'EDITOR_READY:"edited [Dprompt"') # The frame drawn as the key opened the editor names the # footer too; the composer is back only once a new one does. output = b"" os.write(master, b"recovered\r") until(b"EDITOR_DONE") until(b"your messages") os.write(master, b"\x03") # Keep reading: macOS's small pty buffer would otherwise block # the CLI's last frames and its exit behind a full terminal. deadline = time.monotonic() + 10 while process.poll() is None and time.monotonic() < deadline: if select.select([master], [], [], 0.1)[0]: try: os.read(master, 65536) except OSError: break self.assertEqual(process.wait(timeout=10), 0) finally: if process.poll() is None: process.kill() process.wait(timeout=10) os.close(master)