Skip to content

Commit 0cc574a

Browse files
committed
Use pyte for emulating a vt100 terminal and logging the install dialogs
Signed-off-by: Vincent Michel <vincent.michel@vates.tech>
1 parent 295de61 commit 0cc574a

4 files changed

Lines changed: 209 additions & 54 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"requests",
1919
"ipdb",
2020
"paramiko",
21+
"pyte",
2122
]
2223

2324
[dependency-groups]

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ pytest-dependency
1111
requests
1212
ipdb
1313
paramiko
14+
pyte

tests/install/with_tui.py

Lines changed: 193 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
import hashlib
66
import logging
7+
import sys
78
import tempfile
89
import time
910
from pathlib import Path
1011
from uuid import uuid4
1112

1213
import paramiko
14+
import pyte
1315

1416
from lib.common import Defer, wait_for
1517
from lib.host import Host
@@ -139,18 +141,25 @@ def missing_host_key(self, client, hostname, key):
139141
channel.settimeout(30.0)
140142
stdout = channel.makefile('rb', -1)
141143

144+
screen = pyte.Screen(columns=80, lines=24)
145+
stream = pyte.ByteStream(screen)
146+
142147
# Wait for grub to finish
143148
for line in stdout:
144149
if b"Booting `install'" in line:
145150
break
146151

147152
# Wait for TUI to appear
148153
for line in stdout:
154+
assert isinstance(line, bytes)
155+
156+
# Start feeding the terminal emulator from here
157+
stream.feed(line)
158+
149159
if b"Welcome to XCP-ng" in line:
150-
logging.info(f"Entering TUI: {line}")
160+
logging.info(f"Entering TUI: {line!r}")
151161
break
152-
assert isinstance(line, bytes)
153-
if b"\x1b" in line:
162+
elif b"\x1b" in line:
154163
logging.info(f"! {line!r}")
155164
else:
156165
decoded = line.decode(errors="ignore").rstrip()
@@ -159,60 +168,190 @@ def missing_host_key(self, client, hostname, key):
159168
# Maybe some data already got extracted in the stdout buffer
160169
extra_data = getattr(stdout, "_rbuffer")
161170
assert isinstance(extra_data, bytes)
171+
stream.feed(extra_data)
172+
173+
def wait_for_dialog(
174+
title: str,
175+
discriminant: str = "",
176+
delay: float = 1.0,
177+
) -> None:
178+
wrapped_title = f"─┤ {title} ├─"
179+
logging.info(f"Wait for {title!r} dialog title")
180+
while not (
181+
any(wrapped_title in line for line in screen.display)
182+
and any(discriminant in line for line in screen.display)
183+
):
184+
stream.feed(channel.recv(1024))
185+
logging.info(f"Wait for {title!r} dialog to stabilize")
186+
time.sleep(delay)
187+
while channel.recv_ready():
188+
stream.feed(channel.recv(1024))
189+
logging.info(f"{title!r} dialog reached\n{show_screen(screen)}")
190+
191+
def send_tab(n: int = 1, delay: float = 1.0) -> None:
192+
channel.send(b"\t" * n)
193+
time.sleep(delay)
194+
while channel.recv_ready():
195+
stream.feed(channel.recv(1024))
196+
logging.info(f"Selection updated with {n} tab(s)\n{show_screen(screen)}")
197+
198+
def send_up(n: int = 1, delay: float = 1.0) -> None:
199+
channel.send(b"\x1b[A" * n)
200+
time.sleep(delay)
201+
while channel.recv_ready():
202+
stream.feed(channel.recv(1024))
203+
logging.info(f"Selection updated with {n} up(s)\n{show_screen(screen)}")
204+
205+
def send_password(n: int = 1, delay: float = 1.0) -> None:
206+
channel.send(f"{HOST_DEFAULT_PASSWORD}\t".encode() * 2)
207+
time.sleep(delay)
208+
while channel.recv_ready():
209+
stream.feed(channel.recv(1024))
210+
logging.info(f"Selection updated with {n} password(s)\n{show_screen(screen)}")
211+
212+
def send_pagedown(n: int = 1, delay: float = 1.0) -> None:
213+
channel.send(b"\x1b[6~" * n)
214+
time.sleep(delay)
215+
while channel.recv_ready():
216+
stream.feed(channel.recv(1024))
217+
logging.info(f"Selection updated with {n} pagedown(s)\n{show_screen(screen)}")
218+
219+
def validate(expected_selection: str | None = None, expected_validation: str = "Ok") -> None:
220+
highlighted = get_highlighted(screen)
221+
if expected_selection is None:
222+
validation, = highlighted
223+
else:
224+
selected, validation = highlighted
225+
assert expected_selection in selected
226+
assert validation == f" {expected_validation} "
227+
if expected_selection is None:
228+
logging.info(f"Validate dialog using {expected_validation!r}")
229+
else:
230+
logging.info(f"Validate {expected_selection!r} selection using {expected_validation!r}")
231+
channel.send(b"\r")
232+
233+
wait_for_dialog("Select Keymap")
234+
send_tab()
235+
validate(expected_selection="[qwerty] us")
236+
237+
wait_for_dialog("Welcome to XCP-ng Setup")
238+
validate()
239+
240+
wait_for_dialog("End User Agreement")
241+
send_tab()
242+
validate(expected_validation="Accept EUA")
243+
244+
wait_for_dialog("Select Primary Disk")
245+
send_tab()
246+
validate(expected_selection="nvme0n1")
247+
248+
wait_for_dialog("Virtual Machine Storage")
249+
send_tab()
250+
validate(expected_selection="nvme0n1")
251+
252+
wait_for_dialog("Virtual Machine Storage Type")
253+
send_tab(n=2)
254+
validate()
255+
256+
wait_for_dialog("Select Installation Source")
257+
send_tab()
258+
validate(expected_selection="Local media")
259+
260+
wait_for_dialog("Verify Installation Source")
261+
send_up()
262+
send_tab()
263+
validate(expected_selection="Skip verification")
264+
265+
wait_for_dialog("Set Password")
266+
send_password(n=2)
267+
validate()
268+
269+
wait_for_dialog("Networking", discriminant="IPv4")
270+
send_tab(n=3)
271+
validate()
272+
273+
wait_for_dialog("Networking", discriminant="DHCP")
274+
send_tab(n=3)
275+
validate()
276+
277+
wait_for_dialog("Hostname and DNS Configuration")
278+
send_tab(n=5)
279+
validate()
280+
281+
wait_for_dialog("Select Time Zone", discriminant="Africa")
282+
send_pagedown()
283+
send_tab()
284+
validate(expected_selection="Europe")
285+
286+
wait_for_dialog("Select Time Zone", discriminant="Amsterdam")
287+
send_pagedown(n=4)
288+
send_tab()
289+
validate(expected_selection="Paris")
290+
291+
wait_for_dialog("System Time")
292+
send_tab()
293+
validate(expected_selection="Use DHCP NTP servers")
162294

163-
_select_keymap_dialog = wait_for_dialog(channel, b"Select Keymap")
164-
channel.send(b"\t\r") # Validate US
165-
_welcome_dialog = wait_for_dialog(channel, b"Welcome to XCP-ng Setup")
166-
channel.send(b"\r") # Do not reboot, continue
167-
_end_user_agreement_dialog = wait_for_dialog(channel, b"End User Agreement")
168-
channel.send(b"\t\r") # Accept the end user agreement
169-
_select_primary_disk_dialog = wait_for_dialog(channel, b"Select Primary Disk")
170-
channel.send(b"\t\r") # Select first disk
171-
_virtual_machine_storage_dialog = wait_for_dialog(channel, b"Virtual Machine Storage")
172-
channel.send(b"\t\r") # Select first disk
173-
_virtual_machine_storage_type_dialog = wait_for_dialog(channel, b"Virtual Machine Storage Type")
174-
channel.send(b"\t\t\r") # Select EXT
175-
_select_installation_source_dialog = wait_for_dialog(channel, b"Select Installation Source")
176-
channel.send(b"\t\r") # Select Local Media
177-
_verify_installation_source_dialog = wait_for_dialog(channel, b"Verify Installation Source")
178-
channel.send(b"\x1b[A\t\r") # Skip the verification
179-
_set_password_dialog = wait_for_dialog(channel, b"Set Password")
180-
channel.send(f"{HOST_DEFAULT_PASSWORD}\t{HOST_DEFAULT_PASSWORD}\t\r".encode()) # Type root password
181-
_networking_1_dialog = wait_for_dialog(channel, b"Networking")
182-
channel.send(b"\t\t\t\r") # IPv4
183-
_networking_2_dialog = wait_for_dialog(channel, b"Networking")
184-
channel.send(b"\t\t\t\r") # DHCP
185-
_hostname_and_dns_configuration_dialog = wait_for_dialog(channel, b"Hostname and DNS Configuration")
186-
channel.send(b"\t\t\t\t\t\r") # Random hostname and DNS set by DHCP
187-
_select_time_zone_1_dialog = wait_for_dialog(channel, b"Select Time Zone")
188-
channel.send(b"\x1b[6~\r") # Page down to select Europe
189-
_select_time_zone_2_dialog = wait_for_dialog(channel, b"Select Time Zone")
190-
channel.send(b"\x1b[6~\x1b[6~\x1b[6~\x1b[6~\r") # 4 Page down to select Paris
191-
_system_time_dialog = wait_for_dialog(channel, b"System Time")
192-
channel.send(b"\t\r")
193-
_confirm_installation_dialog = wait_for_dialog(channel, b"Confirm Installation")
194-
channel.send(b"\t\r")
295+
wait_for_dialog("Confirm Installation")
296+
send_tab()
297+
validate(expected_validation="Install XCP-ng")
195298

196299
channel.settimeout(600)
197-
_installation_complete_dialog = wait_for_dialog(channel, b"Installation Complete")
198-
199-
200-
def wait_for_dialog(
201-
channel: paramiko.Channel, title: bytes,
202-
dialog: bytes = b"", delay: float = 1.0,
203-
) -> bytes:
204-
logging.info(f"Wait for {title!r} dialog title")
205-
while title not in dialog:
206-
dialog += channel.recv(1024)
207-
logging.info(f"Wait for {title!r} dialog to stabilize")
208-
time.sleep(delay)
209-
while channel.recv_ready():
210-
dialog += channel.recv(1024)
211-
return dialog
212-
213-
def show_dialog(dialog: bytes) -> None:
214-
"""Helper to use when debugging the dialogs"""
215-
print("\x1b[2J\x1b[H" + dialog.decode() + "\x1b[24H\n")
300+
wait_for_dialog("Installation Complete")
301+
302+
def show_screen(screen: pyte.Screen) -> str:
303+
ANSI_RESET = "\033[0m"
304+
ANSI_BOLD = "\033[1m"
305+
ANSI_REVERSE = "\033[7m"
306+
307+
columns = screen.columns
308+
309+
# 1. Draw the top border
310+
result = ["┌" + "─" * columns + "┐"]
311+
312+
# 2. Draw each row with left and right borders
313+
for row_idx in range(screen.lines):
314+
row = screen.buffer[row_idx]
315+
316+
# Start with the left border wall
317+
row_str = "│"
318+
319+
for col_idx in range(columns):
320+
char = row[col_idx]
321+
322+
fmt = ""
323+
if char.bold:
324+
fmt += ANSI_BOLD
325+
if char.reverse:
326+
fmt += ANSI_REVERSE
327+
328+
if fmt:
329+
row_str += f"{fmt}{char.data}{ANSI_RESET}"
330+
else:
331+
row_str += char.data
332+
333+
# Cap the line with the right border wall
334+
row_str += "│"
335+
336+
result.append(row_str)
337+
338+
# 3. Draw the bottom border
339+
result.append("└" + "─" * columns + "┘")
340+
return "\n".join(result)
341+
342+
def get_highlighted(screen: pyte.Screen) -> list[str]:
343+
highlighted = []
344+
for i in range(screen.lines):
345+
row = screen.buffer[i]
346+
previously_reversed = False
347+
for j in range(screen.columns):
348+
char = row[j]
349+
if char.reverse and not previously_reversed:
350+
highlighted.append("")
351+
if char.reverse:
352+
highlighted[-1] += char.data
353+
previously_reversed = char.reverse
354+
return highlighted
216355

217356

218357
@pytest.mark.dependency()

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)