|
| 1 | +# Testing the interactive (curses) installer |
| 2 | + |
| 3 | +The interactive installer's dialog flow (`photon_installer/iso_config.py` |
| 4 | +and the screens under `photon_installer/`) is covered by an automated, |
| 5 | +scripted walkthrough in `tests/test_iso_dialogs.py`. This doc explains how |
| 6 | +that works and how to extend it. |
| 7 | + |
| 8 | +Out of scope here: booting the real ISO end-to-end in a VM. That's handled |
| 9 | +separately, and there's already coverage for installing from an ISO image |
| 10 | +via other tests/tooling. |
| 11 | + |
| 12 | +## Why this is possible |
| 13 | + |
| 14 | +`Installer.configure()` (`photon_installer/installer.py`) cleanly separates |
| 15 | +gathering answers from acting on them: |
| 16 | + |
| 17 | +- The curses dialogs (driven by `IsoConfig.configure()` in |
| 18 | + `photon_installer/iso_config.py`) only build up an `install_config` dict. |
| 19 | +- `Installer.execute()` is the part that actually partitions disks, installs |
| 20 | + packages, etc., and is a separate call that these tests never make. |
| 21 | + |
| 22 | +`iso_config.py` also has a `main()` ("for debugging") that runs just the |
| 23 | +dialog flow standalone via `curses.wrapper(IsoConfig.configure, ui_config)` |
| 24 | +and prints the resulting JSON: |
| 25 | + |
| 26 | +``` |
| 27 | +cd photon_installer |
| 28 | +python3 iso_config.py -f sample_ui_config.json |
| 29 | +``` |
| 30 | + |
| 31 | +`photon_installer/sample_ui_config.json` is a minimal working ui_config |
| 32 | +(`eula_file_path`/`license_display_title` set to `null`, picking up the |
| 33 | +defaults). Both keys are required — `add_ui_pages()` reads them with plain |
| 34 | +dict indexing (`ui_config['eula_file_path']`), so a `ui_config.json` missing |
| 35 | +either one raises a `KeyError` before the first screen even renders; the |
| 36 | +debug `main()` doesn't fill in defaults for them itself. This run uses your |
| 37 | +machine's *real* disks (`SelectDisk` isn't stubbed here — only the |
| 38 | +automated tests stub it) but is still safe: this path only ever calls |
| 39 | +`IsoConfig.configure()`, never `Installer.execute()`, so nothing is written |
| 40 | +to disk regardless of what you select. |
| 41 | + |
| 42 | +That's the manual, human-drivable way to click through the screens for a |
| 43 | +quick sanity check. `tests/test_iso_dialogs.py` automates the same idea. |
| 44 | + |
| 45 | +Only one screen touches the real system: `SelectDisk` / |
| 46 | +`CustomPartition` call `Device.refresh_devices()` |
| 47 | +(`photon_installer/device.py`), which shells out to `lsblk`. That's stubbed |
| 48 | +out for the tests (see below). `NetworkConfigure` does **not** probe real |
| 49 | +network interfaces — it only ever offers a fixed menu of DHCP/static/VLAN |
| 50 | +options — so it needs no stubbing. |
| 51 | + |
| 52 | +## Driving the TUI with `pexpect` |
| 53 | + |
| 54 | +`tests/tui_driver.py` wraps `pexpect`: it spawns the dialog process in a |
| 55 | +pty and lets tests `expect()` known marker text (window titles, prompts) |
| 56 | +and `send()` key sequences (arrows, tab, enter, plain text) in response — |
| 57 | +no terminal-emulation/screen-buffer library needed. Each curses screen |
| 58 | +renders distinctive, literal strings (e.g. `"Select a disk"`, `"Choose the |
| 59 | +hostname for your system"`) that survive being interleaved with |
| 60 | +ANSI/cursor-movement bytes, so sequential `expect()` calls reliably confirm |
| 61 | +which screen is up before the next batch of keys is sent. |
| 62 | + |
| 63 | +Known limitation: `pexpect` has no screen model, so it can't tell which |
| 64 | +menu item currently has the highlight (that's conveyed via color/reverse |
| 65 | +video, not text). Tests work around this by scripting a fixed, known-good |
| 66 | +key sequence per screen (e.g. "press Down once, then Enter" to move off the |
| 67 | +default choice) instead of reading back highlight state. |
| 68 | + |
| 69 | +`TERM` is pinned to `"linux"` in `tui_driver.py` rather than left to the |
| 70 | +environment: the `"linux"` terminfo entry maps arrow keys to the plain |
| 71 | +`ESC [ A/B/C/D` byte sequences, whereas e.g. `"xterm"`'s terminfo uses |
| 72 | +`ESC O A/B/C/D` — an easy way to get silently-ignored keystrokes if `TERM` |
| 73 | +varies by environment. |
| 74 | + |
| 75 | +## Fixtures/stubs (`tests/fixtures/`) |
| 76 | + |
| 77 | +`tests/fixtures/iso_config_stub_entrypoint.py` is what `pexpect` actually |
| 78 | +spawns. Before calling `IsoConfig().configure()` it patches out three real |
| 79 | +external dependencies the dialogs would otherwise pull in, so the tests |
| 80 | +have no system-package requirements and no dependence on the machine |
| 81 | +they're running on: |
| 82 | + |
| 83 | +- `Device.refresh_devices` → returns a small fixed list of fake disks |
| 84 | + (default: one 10 GiB disk at `/dev/fakea`), overridable via the |
| 85 | + `POI_TEST_FAKE_DISKS` env var (JSON list of |
| 86 | + `{"model", "path", "size_bytes"}`). |
| 87 | +- `cracklib.VeryFascistCheck` → faked to accept any password. Password |
| 88 | + strength policy isn't what these tests exercise. |
| 89 | +- `CommandUtils.generate_password_hash` → faked to avoid shelling out to |
| 90 | + the real `mkpasswd` binary (from the `whois` package). The dialogs only |
| 91 | + care that *some* string ends up in `install_config['shadow_password']`. |
| 92 | + |
| 93 | +`tests/fixtures/packages_options.json` is a package-options file (the |
| 94 | +format normally passed as `--options-file`/`ui_config['options_file']`) |
| 95 | +with two visible options, both including `"linux"` and `"linux-rt"` in |
| 96 | +their package lists. Two visible options keeps the package-selection screen |
| 97 | +active (a single-option file gets silently auto-skipped by |
| 98 | +`PackageSelector`), and having two non-conflicting kernel flavors present |
| 99 | +keeps the linux-kernel-selection screen active too (`LinuxSelector` |
| 100 | +auto-skips itself when fewer than two flavors are available). This makes |
| 101 | +both screens deterministically part of the flow regardless of what |
| 102 | +environment the test runs in. |
| 103 | + |
| 104 | +## The tests: `tests/test_iso_dialogs.py` |
| 105 | + |
| 106 | +`_run_dialogs()` walks the full screen sequence — license, disk selection, |
| 107 | +packages, network, kernel flavor, STIG, hostname, root password (x2), |
| 108 | +final confirmation — accepting the default choice at each screen unless a |
| 109 | +test overrides one step (currently only the STIG screen, via the |
| 110 | +`on_stig_screen` callback). It returns the final `install_config` as a |
| 111 | +parsed dict for assertions. |
| 112 | + |
| 113 | +Two tests today: |
| 114 | + |
| 115 | +- `test_happy_path_auto_partition_dhcp` — every default accepted. |
| 116 | +- `test_stig_hardening_enabled` — same flow, but presses Down+Enter on the |
| 117 | + STIG screen and asserts `ansible`/`additional_packages` show up. |
| 118 | + |
| 119 | +To add another variant (e.g. static IP networking, custom partitioning), |
| 120 | +add a new test that calls `_run_dialogs()` with an override callback for |
| 121 | +the relevant screen, following the `on_stig_screen` pattern — or extend |
| 122 | +`_run_dialogs()` with another optional callback parameter if the new |
| 123 | +variant needs to diverge earlier/later in the sequence. |
| 124 | + |
| 125 | +Since this only ever calls `IsoConfig.configure()` (never |
| 126 | +`Installer.execute()`), these tests touch no real disks/packages/network |
| 127 | +and run in ~1s each — a normal, fast part of CI |
| 128 | +(`.github/workflows/photon-os-installer.yml`), unlike a real install. |
| 129 | + |
| 130 | +## Dependency |
| 131 | + |
| 132 | +- `pexpect` (installed in CI via `pip install pytest pexpect`; not added to |
| 133 | + `requirements.txt` since it's test-only, not a runtime dependency of the |
| 134 | + installer itself). |
| 135 | + |
| 136 | +## Explicitly not covered here |
| 137 | + |
| 138 | +- Booting the actual ISO in QEMU/a VM and driving the installer over a |
| 139 | + serial console for a true end-to-end interactive install. This is handled |
| 140 | + separately; there's already coverage for installing from an ISO image via |
| 141 | + other tests/tooling. |
0 commit comments