44
55import hashlib
66import logging
7+ import sys
78import tempfile
89import time
910from pathlib import Path
1011from uuid import uuid4
1112
1213import paramiko
14+ import pyte
1315
1416from lib .common import Defer , wait_for
1517from 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 ()
0 commit comments