* Add OEM install mode and factory snapshots OEM mode installs the entire system but defers user creation to first boot, where the runtime's omarchy-oem-setup asks for the owner and lands them in the already-installed system — and the same machinery arms omarchy-reset-computer. The configurator's install-mode step gains an OEM choice, which required moving the user step after mode selection (OEM skips it entirely). Autoinstall rigs select OEM with an `oem` marker file on the cidata drive, which also replaces the user_credentials.json requirement. In the orchestrator, OEM swaps the user phases for a stage_oem_state phase: it stashes the bundled Node tarball (every install, for offline factory resets), arms omarchy-oem-setup.service in the target, and on encrypted installs stages the throwaway LUKS passphrase — generated by InstallContext when the input carries none — with a keyfile embedded in the UKI so boot auto-unlocks until first-boot setup re-keys to the owner's password. The install fails with a clear error if the bundled runtime lacks OEM support. Every install now also ends with a read-only @factory snapshot of @ at the btrfs top level — outside snapper's reach — which is what makes omarchy-reset-computer a true factory reset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Offer OEM mode only when the bundled runtime supports it The configurator now peeks into the offline mirror's runtime package for omarchy-oem-setup before offering the OEM install mode, so an ISO built against a pre-OEM runtime can't start an install doomed to fail at the staging phase (which still hard-checks as the backstop). Blank disks with no OEM choice to offer skip the mode picker entirely, like the pre-OEM flow. Also keep the test harness QMP socket in /tmp — unix socket paths cap at ~108 bytes, which nested test-run directories can exceed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Isolate the test harness SSH identity from the local ssh-agent A loaded agent offers its keys before the harness identity and can exhaust the server's MaxAuthTries before the right key is ever tried; IdentitiesOnly pins authentication to the per-run key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make OEM state world-readable for user-context finalization The Node tarball in /var/lib/omarchy/oem/packages is read by omarchy-finalize-user running as the freshly created user; a 0700 directory forced the offline install onto its network fallback. The only secret inside (luks-key) is itself 0600 root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep provisioning credentials out of OEM installs and factory snapshots Review findings from codex and Copilot: - A rig-supplied credentials file in OEM mode now contributes only its LUKS passphrase: preserved users/root_enc_password fields would have created a hidden provisioning account despite OEM promising no user until first boot. - The factory snapshot is taken writable, scrubbed of deployment credentials (staged authorized_keys, tailscale auth key and join unit, the dead OEM LUKS key and its initramfs drop-ins), then set read-only — a reset years later must not authorize the original deployment key or rejoin its tailnet. - The missing-Node-tarball case is a hard install error on every install, not just OEM: the stash is what lets a factory reset finalize offline. - The configurator's OEM gate checks the runtime package for the service unit as well as the setup binary. - The test harness handles the deliberately skipped mode picker on blank disks when the bundled runtime lacks OEM support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Second review pass: strip account fields, clear stale cidata inputs, await OEM finalization Codex xhigh round 2: - Strip account authentication fields (!users, !root-password, root_enc_password, auth_config account entries) from the archinstall config in OEM mode, not just user_credentials — a combined/legacy config could otherwise have archinstall create a provisioning account before OOBE. - Clear stale OEM inputs (oem marker, credentials, optional files) from /root before copying a cidata drive, so a retried install in the same live session can't force a fresh drive back into OEM mode or reuse an old LUKS passphrase. - The test harness waits for the OEM pending flag to clear before saving the base image, so it never snapshots a half-finalized first boot; it also handles the deliberately skipped mode picker on blank disks built against a pre-OEM runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reorder configurator: disk before keyboard, skip keyboard for OEM The wizard now opens on disk selection; keyboard moves to after the install mode is chosen and stays before the user step (so the LUKS passphrase is still typed under the chosen layout). OEM installs skip keyboard and user entirely — the machine's owner picks both at first boot — so the emitted config just carries a default 'us' keymap as a placeholder. The QEMU harness drives the new order and the OEM first-boot keyboard step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harness: prove the OEM keyboard step applies a non-default layout Drive the OEM first-boot keyboard picker to English (UK) and assert the installed system ends up with KEYMAP=uk and XKBLAYOUT=gb — proving the deferred keyboard step actually applied a layout, not just rendered. UK keeps every letter identical to US so the raw-keycode password still matches, and the email's '@' (Shift+' under UK, not Shift+2) is typed with the UK keycode. The console bootstrap now fetches-then-runs instead of piping, so it survives the non-US layout the keyboard step installs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update OEM comments to the renamed factory-reset command Reference omarchy-system-factory-reset (was omarchy-reset-computer) in the orchestrator comments and the plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop the configurator's runtime-support gate for OEM mode Remove oem_supported() and its branches: the configurator always offers OEM install now, rather than scanning the offline mirror's runtime package to decide. Building an ISO against a runtime that ships OEM support is the build's responsibility; the orchestrator still fails an OEM install with a clear error if the staged target somehow lacks omarchy-oem-setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * OEM mode via Ctrl+C on the first screen, not a mode-picker entry Restore the original screen order (keyboard first, then disk) and make Ctrl+C on the keyboard screen the entry into OEM mode — the same hidden-Ctrl+C idiom the disk-overwrite screen already uses for unencrypted installs. A confirm guards against an accidental Ctrl+C; once confirmed, OEM jumps straight to disk selection and the overwrite confirmation (no keyboard, no user), since OEM is always a full-disk install. The 'OEM install' entry leaves the mode picker. The harness drives the new path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore keyboard→user→disk order; drop OEM wording from the configurator UI The normal install flow returns to its original order: keyboard, then the user step, then disk selection and mode. The Ctrl+C shortcut on the first screen and its confirmation now speak only of preparing the machine for another owner — no 'OEM' in any user-facing string (the omarchy_install.oem data contract is unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only show the mode picker when free space is also an option Restore the pre-OEM behavior: a disk with no unallocated free space has just one viable mode, so the wizard skips the mode picker and goes straight to the overwrite confirmation. The picker appears only when a free-space install is also on offer. Also give the harness's first-boot SSH bootstrap more headroom (360s): an encrypted OEM first boot re-keys LUKS and rebuilds the UKI before sshd settles, which can exceed 180s under parallel VM load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten the prepare-for-another-owner confirm wording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keyboard screen: Ctrl+C hint directly under the header, no blank line Match the disk-overwrite screen, where the unencrypted-install hint sits on the line right below its message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Rename OEM vocabulary to provisioning (ISO) Matches the runtime rename. The deferred-provisioning install mode replaces 'OEM' everywhere: omarchy_install.oem → omarchy_install.defer_provisioning --oem / --oem-file → --defer-provisioning / --defer-provisioning-file OMARCHY_INSTALL_OEM_FILE → OMARCHY_INSTALL_DEFER_PROVISIONING_FILE cidata 'oem' marker → 'defer-provisioning' /var/lib/omarchy/oem/ → /var/lib/omarchy/provisioning/ /etc/omarchy/oem.key → /etc/omarchy/provisioning.key install/oem/omarchy-oem-setup.service → install/provisioning/omarchy-provision-owner.service stage_oem_state phase → stage_provisioning_state ('Staging provisioning') Orchestrator internals (OEM_STATE_DIR, _validate_oem_state, ctx.oem, …), the configurator, cidata-load, the dashboard phase band, the harness (--oem → --provision), and the tests all move together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix orchestrator caller of the renamed user-provisioning command run_chroot_finalizer still invoked /usr/bin/omarchy-finalize-user, which the rename moved to omarchy-provision-user — so a normal install died with 'No such file'. (Deferred-provisioning installs skip this phase, so only normal installs were affected.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wait for the live VT to settle before drawing the first screen tty1 boots as an 80-column console and only widens once the kernel framebuffer/KMS takes over, which lands late — especially in VMs. The configurator's logo is wider than 80 columns, so the very first draw measured against that transient narrow console: the logo wrapped ("smushed") and, with left padding clamped to zero, hugged the left edge. Every later screen re-measures once the console has grown, which is why only the opening keyboard screen looked wrong. Hold the first draw until the width settles at or above the logo width, capped so a console that never widens still proceeds. Terminal emulators (dry runs) are already at final size and are skipped. Co-Authored-By: Claude <noreply@anthropic.com> * Confirm the Start Omarchy Now prompt after provisioning setup (#100) * Confirm the Start Omarchy Now prompt after provisioning setup First-boot account setup now ends on a celebration screen whose Start Omarchy Now button gates the SDDM handoff, so the provisioning test must press it — same as the installer's Reboot Now prompt — once the pending flag clears. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add a centered greeter before the installer keyboard step Open the ISO configurator with the same greeter the deferred-provisioning first boot uses — vertically centered logo, the Omarchy tagline, and a single button — so install and first boot bookend setup identically. The installer's button reads "Start install"; first boot's reads "Start setup". Co-Authored-By: Claude <noreply@anthropic.com> * Animate the installer greeter logo with a skippable ColorShift Match the first-boot greeter: a looping tte ColorShift over the centered logo with a "Press Return to Start Install" hint instead of a button. Return begins the install at any time. Co-Authored-By: Claude <noreply@anthropic.com> * Theme the installer greeter ColorShift to the Omarchy palette Match the first-boot greeter: sweep the logo ColorShift through the Tokyo Night accent colors instead of tte's default rainbow. Co-Authored-By: Claude <noreply@anthropic.com> * Fix installer greeter ColorShift: green base, no flash, clean handoff Mirror the first-boot greeter fixes: indexed ANSI colors (green base with a cyan accent) because the framebuffer console can't render tte truecolor; one long tte invocation so the loop never flashes; stty sane + clear after Return so the keyboard step starts clean. Co-Authored-By: Claude <noreply@anthropic.com> * Dismiss the greeter in the QEMU test drivers The installer and first-boot flows now open with a greeter (animated logo + tagline). Wait for the tagline and press Return before the keyboard step, in both drive paths, so the harness gets past the greeter. Co-Authored-By: Claude <noreply@anthropic.com> * Kill the installer greeter animation cleanly Run tte directly instead of inside a backgrounded `while` subshell, so killing the tracked PID actually stops the effect. Killing the subshell orphaned tte, which kept painting the logo over the keyboard step. Co-Authored-By: Claude <noreply@anthropic.com> * Guard the greeter wait against set -e in the configurator too Harmless today (no set -e), but keeps the greeter's animation teardown robust if that changes. Co-Authored-By: Claude <noreply@anthropic.com> * Also settle the terminal in emulators, not just real VTs wait_for_stable_terminal skipped terminal emulators on the assumption they're already at final size, but a fresh terminal (or sudo's pty) reports a stale 24x80 for a moment at startup too. Drop the VT-only gate so the greeter centers there as well. Co-Authored-By: Claude <noreply@anthropic.com> * Test harness: drive the first-boot timezone step and direct SDDM handoff Match the deferred-provisioning first-boot flow changes: the owner now picks a timezone after the email step, and setup hands straight off to SDDM instead of pausing on a celebration screen with a Start Omarchy Now button. Drive the new Timezone screen (accept the geo-guessed default) and drop the button confirmation. Co-Authored-By: Claude <noreply@anthropic.com> * Don't bake hostname/timezone into deferred installs; set them at first boot Deferred provisioning defers the user step to first boot, so the machine's owner should name the machine and pick the timezone there — not inherit values chosen at install time. Stop setting hostname/timezone in the configurator's deferred branch: the orchestrator already skips an empty timezone (the system stays UTC until first boot), and it now falls back to a neutral "archlinux" hostname for the install so archinstall still succeeds. The first-boot omarchy-provision-owner form collects and applies both. Harness: drive the new Hostname screen and assert both hostname and timezone actually landed on the running system after first boot. Co-Authored-By: Claude <noreply@anthropic.com> * Match the installer greeter: solid green logo, no color animation Same fix as the first-boot greeter — tte's ColorShift judders on the 16-color framebuffer console (truecolor crush). Show the logo in solid green and drop the animation so both greeters look identical and flash-free. Co-Authored-By: Claude <noreply@anthropic.com> * Harden the provision harness: SSH-blip wait, timezone timeout, hostname assert - The pending-flag wait treated an SSH connection failure (rc 255) the same as "flag absent" (rc 1), so a transient blip during finalization would exit early and snapshot the base mid-finalization. Only break on a clean rc 1; keep waiting on 0 or 255. - Bump the first-boot Timezone screen wait 60s -> 180s (tzupdate geo-guess over first-boot networking can lag; matches the installer's headroom). - Make the applied-hostname check a hard failure (it's deterministic — we typed it), so a first-boot apply regression can't pass green; timezone stays a report since it's geo-guessed. Co-Authored-By: Claude <noreply@anthropic.com> * Seed hostname=omarchy/timezone=UTC in deferred install instead of empties Write neutral valid defaults in the configurator's deferred branch rather than empty strings, and drop the orchestrator's `config.hostname or "archlinux"` fallback (hostname is always non-empty now). The first-boot wizard overwrites both. This keeps the "owner sets them at first boot" behavior without depending on archinstall tolerating empty hostname/timezone at config-load time. Co-Authored-By: Claude <noreply@anthropic.com> * Fix set -e regression in the provision pending-wait loop The previous hardening moved `ssh_guest "test -f pending"` out of the `while` condition into a standalone command, but the harness runs `set -euo pipefail` — so the moment the flag cleared (ssh returns 1, the intended "done" signal) it tripped errexit and aborted the whole run right after provisioning, before the session/acceptance phase. Capture the result with `|| rc=$?` so the non-zero is the signal, not a fatal error. Also guard the follow-on hostname/timezone substitutions with `|| got=""` so a transient SSH failure there can't abort either (an empty value then correctly fails the hostname assertion). Co-Authored-By: Claude <noreply@anthropic.com> * Bring back the installer greeter ColorShift animation Match the first-boot greeter: restore the static green logo + looping tte ColorShift overlay. The framebuffer truecolor crush gives it a quick flash, which is acceptable — both greeters keep the animation. Co-Authored-By: Claude <noreply@anthropic.com> * Skip the install celebration on deferred installs; reboot directly A deferred-provisioning install has nothing to celebrate yet — the owner finishes setup (and gets the send-off) at first boot. So the installer's "Installed Omarchy in ..." screen and the Reboot Now prompt now belong to a direct install only: for a deferred install the dashboard clears and reboots on its own. .automated_script.sh detects deferred mode (the config's defer_provisioning flag, or the cidata defer-provisioning marker) and passes OMARCHY_UI_DEFER_PROVISIONING to the dashboard. Harness: a deferred install no longer shows Reboot Now, so wait_for_install recognizes the auto-reboot by the first-boot greeter tagline appearing. Co-Authored-By: Claude <noreply@anthropic.com> * Detect deferred mode with jq, not a line regex The dashboard's deferred detection grepped user_configuration.json for the defer_provisioning boolean on one line; a reformatted config would read as a direct install and wrongly show the celebration. Parse it with jq using the same path the orchestrator reads (.omarchy_install.defer_provisioning). Co-Authored-By: Claude <noreply@anthropic.com> * Drop Bosnian and Khmer from the installer keyboard picker Match the first-boot picker: neither has a console keymap, so both silently fell back to US. Remove them rather than offer a layout that can't apply. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1115 lines
35 KiB
Bash
Executable File
1115 lines
35 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Acceptance test harness: boots an Omarchy ISO in a headless QEMU VM and
|
|
# drives the real interactive install flow the way a user would — reading
|
|
# each screen through QMP screendumps + OCR, and answering with virtual
|
|
# keystrokes. Nothing in the product is bypassed or stubbed: the configurator
|
|
# wizard, install dashboard, reboot prompt, and SDDM login are all exercised.
|
|
#
|
|
# After first boot it logs into a console TTY (typed, like everything else)
|
|
# to authorize an SSH key, snapshots the installed disk, then boots a
|
|
# throwaway overlay and runs the in-guest acceptance suite (test/acceptance
|
|
# in the omarchy repo) over SSH.
|
|
#
|
|
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
|
|
#
|
|
# --encrypt Drive the encrypted install flow (default: unencrypted)
|
|
# --provision Drive the deferred-provisioning install flow: no user during install, the
|
|
# first boot runs omarchy-provision-owner and creates it there
|
|
# --reuse-base Skip the install phase if a base image already exists
|
|
# --install-only Stop after producing the installed base image
|
|
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
|
|
# (defaults to OMARCHY_PATH when the suite is present)
|
|
# --sync-all DIR Push all of DIR (bin/config/shell/test) into the guest
|
|
# --port PORT Host port forwarded to guest SSH (default 2222)
|
|
# --memory MB VM memory (default 8192)
|
|
# --timeout SECS Install timeout (default 2400)
|
|
# --keep-running Leave the acceptance VM running after the suite finishes
|
|
# --no-preview Do not open the collected screenshots in imv
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
|
|
|
ISO=""
|
|
SSH_PORT=2222
|
|
MEMORY=8192
|
|
INSTALL_TIMEOUT=2400
|
|
BOOT_TIMEOUT=600
|
|
ENCRYPT=false
|
|
PROVISION=false
|
|
REUSE_BASE=false
|
|
INSTALL_ONLY=false
|
|
KEEP_RUNNING=false
|
|
NO_PREVIEW=false
|
|
SYNC_DIR=""
|
|
SYNC_ALL=false
|
|
|
|
GUEST_USER="omarchy"
|
|
GUEST_PASSWORD="omarchy"
|
|
GUEST_HOSTNAME="omarchy-test"
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
--encrypt) ENCRYPT=true ;;
|
|
--provision) PROVISION=true ;;
|
|
--reuse-base) REUSE_BASE=true ;;
|
|
--install-only) INSTALL_ONLY=true ;;
|
|
--keep-running) KEEP_RUNNING=true ;;
|
|
--no-preview) NO_PREVIEW=true ;;
|
|
--sync-omarchy) SYNC_DIR="$2"; shift ;;
|
|
--sync-all) SYNC_DIR="$2"; SYNC_ALL=true; shift ;;
|
|
--port) SSH_PORT="$2"; shift ;;
|
|
--memory) MEMORY="$2"; shift ;;
|
|
--timeout) INSTALL_TIMEOUT="$2"; shift ;;
|
|
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
*) ISO="$1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ -z $SYNC_DIR && -f ${OMARCHY_PATH:-}/test/acceptance ]]; then
|
|
SYNC_DIR="$OMARCHY_PATH"
|
|
fi
|
|
|
|
if [[ -z $ISO && -t 0 ]]; then
|
|
ISO=$(ls -t "$REPO_DIR"/release/*.iso 2>/dev/null | gum choose --header "Select ISO to test")
|
|
fi
|
|
|
|
if [[ ! -f ${ISO:-} ]]; then
|
|
echo "No ISO to test. Pass one explicitly or put it in release/." >&2
|
|
echo "Usage: omarchy-iso-test [release/omarchy.iso] [options]" >&2
|
|
exit 1
|
|
fi
|
|
|
|
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
|
|
|
|
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
|
|
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
|
|
|
BASE_NAME="$(basename "$ISO" .iso)"
|
|
$ENCRYPT && BASE_NAME+="-encrypted"
|
|
$PROVISION && BASE_NAME+="-provision"
|
|
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
|
|
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
|
|
BASE_DISK="$BASE_DIR/base.qcow2"
|
|
BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd"
|
|
SSH_KEY="$BASE_DIR/id_ed25519"
|
|
HTTP_PORT=$((SSH_PORT + 1))
|
|
HTTP_PID=""
|
|
|
|
mkdir -p "$BASE_DIR" "$RUN_DIR"
|
|
|
|
# Unix socket paths are capped at ~108 bytes, which a run directory nested in
|
|
# test-runs/<iso-name>/runs/<timestamp> can exceed. Keep the socket in /tmp.
|
|
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX.sock")
|
|
PIDFILE="$RUN_DIR/qemu.pid"
|
|
|
|
log() {
|
|
printf '\033[1;35m==> %s\033[0m\n' "$1"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- vm control
|
|
|
|
vm_running() {
|
|
[[ -f $PIDFILE ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
|
|
}
|
|
|
|
qmp() {
|
|
printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" |
|
|
timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
|
|
}
|
|
|
|
screendump() {
|
|
qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null
|
|
}
|
|
|
|
capture_console() {
|
|
local name="$1" shot="$RUN_DIR/.capture.ppm"
|
|
|
|
sleep 1
|
|
screendump "$shot"
|
|
[[ -s $shot ]] || return 0
|
|
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null || true
|
|
rm -f "$shot"
|
|
}
|
|
|
|
stop_vm() {
|
|
vm_running || return 0
|
|
|
|
if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then
|
|
qmp '"system_powerdown"' >/dev/null
|
|
fi
|
|
|
|
local waited=0
|
|
while vm_running && ((waited < 15)); do
|
|
sleep 1
|
|
((waited += 1))
|
|
done
|
|
|
|
if vm_running; then
|
|
qmp '"quit"' >/dev/null
|
|
sleep 1
|
|
fi
|
|
|
|
if vm_running; then
|
|
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
|
sleep 1
|
|
fi
|
|
}
|
|
|
|
open_screenshots() {
|
|
local entry
|
|
local -a screenshots=()
|
|
|
|
while IFS= read -r -d '' entry; do
|
|
screenshots+=("${entry#* }")
|
|
done < <(
|
|
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
|
|
sort -zn
|
|
)
|
|
|
|
(( ${#screenshots[@]} > 0 )) || return 0
|
|
|
|
log "Visual review: ${#screenshots[@]} screenshots in $RUN_DIR"
|
|
$NO_PREVIEW && return 0
|
|
|
|
if command -v imv >/dev/null && [[ -n ${WAYLAND_DISPLAY:-}${DISPLAY:-} ]]; then
|
|
setsid -f imv "${screenshots[@]}" >/dev/null 2>&1
|
|
fi
|
|
}
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
|
|
[[ -n $HTTP_PID ]] && kill "$HTTP_PID" 2>/dev/null || true
|
|
|
|
if ! $KEEP_RUNNING; then
|
|
vm_running && log "Stopping test VM"
|
|
stop_vm
|
|
fi
|
|
|
|
rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
|
|
open_screenshots
|
|
|
|
return $status
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
start_vm() {
|
|
local disk="$1" serial="$2"
|
|
shift 2
|
|
|
|
qemu-system-x86_64 \
|
|
-cpu host -enable-kvm -machine q35,accel=kvm \
|
|
-smp "$(nproc)" \
|
|
-m "$MEMORY" \
|
|
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
|
|
-drive if=pflash,format=raw,file="$BASE_OVMF" \
|
|
-drive file="$disk",format=qcow2,if=none,id=drive0 \
|
|
-device virtio-blk-pci,drive=drive0,bootindex=1 \
|
|
-device virtio-vga \
|
|
-display none \
|
|
-usb -device usb-tablet \
|
|
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
|
|
-device virtio-net-pci,netdev=net0 \
|
|
-qmp "unix:$QMP_SOCK,server,nowait" \
|
|
-serial "file:$serial" \
|
|
-pidfile "$PIDFILE" \
|
|
-daemonize \
|
|
"$@"
|
|
}
|
|
|
|
# ------------------------------------------------------------ console driver
|
|
#
|
|
# The heart of the harness: read the VM's console with screendump + OCR, and
|
|
# type into it with QMP send-key. Together they make an expect-style loop
|
|
# that walks the same screens a user sees.
|
|
|
|
ocr_screen() {
|
|
local shot="$RUN_DIR/.screen.ppm" prepped="$RUN_DIR/.screen.png"
|
|
|
|
screendump "$shot"
|
|
[[ -s $shot ]] || return 0
|
|
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
|
|
tesseract "$prepped" - --psm 6 2>/dev/null || true
|
|
}
|
|
|
|
# Wait until the console shows the given text. Saves the offending screen as
|
|
# an artifact on timeout.
|
|
wait_for_screen() {
|
|
local text="$1" timeout="$2" waited=0 slug
|
|
|
|
until ocr_screen | grep -qi "$text"; do
|
|
if ! vm_running; then
|
|
echo "VM exited while waiting for screen: $text" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ((waited >= timeout)); then
|
|
slug=${text,,}
|
|
slug=${slug// /-}
|
|
slug=${slug//[^a-z0-9-]/}
|
|
capture_console "failure-installer-waiting-for-$slug"
|
|
echo "Timed out after ${timeout}s waiting for screen: $text" >&2
|
|
return 1
|
|
fi
|
|
|
|
sleep 3
|
|
((waited += 3))
|
|
done
|
|
|
|
sleep 1 # let the screen settle before typing
|
|
}
|
|
|
|
# Press a key or chord, dash-separated: "ret", "ctrl-c", "ctrl-alt-f3".
|
|
press() {
|
|
local part json=""
|
|
local -a parts
|
|
|
|
IFS='-' read -ra parts <<<"$1"
|
|
for part in "${parts[@]}"; do
|
|
json+="{\"type\":\"qcode\",\"data\":\"$part\"},"
|
|
done
|
|
|
|
qmp "\"send-key\", \"arguments\": {\"keys\": [${json%,}]}" >/dev/null
|
|
}
|
|
|
|
type_text() {
|
|
local text="$1" ch i
|
|
|
|
for ((i = 0; i < ${#text}; i++)); do
|
|
ch=${text:i:1}
|
|
case "$ch" in
|
|
[a-z0-9]) press "$ch" ;;
|
|
[A-Z]) press "shift-${ch,,}" ;;
|
|
" ") press spc ;;
|
|
.) press dot ;;
|
|
,) press comma ;;
|
|
-) press minus ;;
|
|
_) press shift-minus ;;
|
|
/) press slash ;;
|
|
:) press shift-semicolon ;;
|
|
@) press shift-2 ;;
|
|
\|) press shift-backslash ;;
|
|
";") press semicolon ;;
|
|
"'") press apostrophe ;;
|
|
'"') press shift-apostrophe ;;
|
|
"=") press equal ;;
|
|
"+") press shift-equal ;;
|
|
"%") press shift-5 ;;
|
|
"&") press shift-7 ;;
|
|
"*") press shift-8 ;;
|
|
"(") press shift-9 ;;
|
|
")") press shift-0 ;;
|
|
"<") press shift-comma ;;
|
|
">") press shift-dot ;;
|
|
"!") press shift-1 ;;
|
|
"#") press shift-3 ;;
|
|
'$') press shift-4 ;;
|
|
"?") press shift-slash ;;
|
|
"~") press shift-grave_accent ;;
|
|
"\\") press backslash ;;
|
|
"[") press bracket_left ;;
|
|
"]") press bracket_right ;;
|
|
"{") press shift-bracket_left ;;
|
|
"}") press shift-bracket_right ;;
|
|
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
|
|
esac
|
|
sleep 0.05
|
|
done
|
|
}
|
|
|
|
# --------------------------------------------------------------------- guest
|
|
|
|
ssh_guest() {
|
|
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
|
|
-o BatchMode=yes \
|
|
-o IdentitiesOnly=yes \
|
|
-o StrictHostKeyChecking=no \
|
|
-o UserKnownHostsFile=/dev/null \
|
|
-o ConnectTimeout=5 \
|
|
-o LogLevel=ERROR \
|
|
"$GUEST_USER@127.0.0.1" "$@"
|
|
}
|
|
|
|
wait_for_ssh() {
|
|
local timeout="$1" failure_name="${2:-failure-session-ssh-timeout}" waited=0
|
|
|
|
while ! ssh_guest true 2>/dev/null; do
|
|
if ! vm_running; then
|
|
echo "VM exited while waiting for SSH" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ((waited >= timeout)); then
|
|
capture_console "$failure_name"
|
|
echo "Timed out after ${timeout}s waiting for SSH" >&2
|
|
return 1
|
|
fi
|
|
|
|
sleep 5
|
|
((waited += 5))
|
|
done
|
|
}
|
|
|
|
session_started() {
|
|
ssh_guest "ls /run/user/1000/hypr 2>/dev/null | grep -q ." 2>/dev/null
|
|
}
|
|
|
|
ssh_session() {
|
|
local command="$1"
|
|
|
|
ssh_guest "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
|
|
export DBUS_SESSION_BUS_ADDRESS=unix:path=\$XDG_RUNTIME_DIR/bus; \
|
|
export LANG=\$(systemctl --user show-environment | sed -n 's/^LANG=//p' | head -1); \
|
|
export LANG=\${LANG:-C.UTF-8}; \
|
|
export HYPRLAND_INSTANCE_SIGNATURE=\$(ls -t \$XDG_RUNTIME_DIR/hypr | head -1); \
|
|
export WAYLAND_DISPLAY=\$(find \$XDG_RUNTIME_DIR -maxdepth 1 -name 'wayland-*' ! -name '*.lock' -printf '%f\\n' | head -1); \
|
|
export OMARCHY_PATH=/usr/share/omarchy; \
|
|
export PATH=\$OMARCHY_PATH/bin:\$PATH; \
|
|
$command" </dev/null
|
|
}
|
|
|
|
wait_for_guest_state() {
|
|
local description="$1" timeout="$2"
|
|
shift 2
|
|
local deadline=$((SECONDS + timeout))
|
|
|
|
until "$@" >/dev/null 2>&1; do
|
|
if ((SECONDS >= deadline)); then
|
|
printf 'not ok - %s\n' "$description" >&2
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
printf 'ok - %s\n' "$description"
|
|
}
|
|
|
|
guest_layer_present() {
|
|
local namespace="$1"
|
|
|
|
ssh_session "hyprctl -j layers | jq -e --arg namespace '$namespace' \
|
|
'[.. | objects | select(.namespace? == \$namespace)] | length > 0'"
|
|
}
|
|
|
|
guest_layer_absent() {
|
|
! guest_layer_present "$1"
|
|
}
|
|
|
|
cleanup_shortcut_state() {
|
|
ssh_session 'for plugin in omarchy.launcher omarchy.menu omarchy.emojis omarchy.clipboard omarchy.audio; do
|
|
omarchy-shell shell hide "$plugin" >/dev/null 2>&1 || true
|
|
done
|
|
while read -r address; do
|
|
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null 2>&1 ||
|
|
hyprctl dispatch closewindow "address:$address" >/dev/null 2>&1 || true
|
|
done < <(hyprctl -j clients | jq -r '\''.[] | select(.class == "foot") | .address'\'')
|
|
hyprctl dispatch workspace 1 >/dev/null 2>&1 || true' >/dev/null 2>&1 || true
|
|
}
|
|
|
|
verify_surface_shortcut() {
|
|
local name="$1" chord="$2" namespace="$3" plugin="$4"
|
|
|
|
press "$chord"
|
|
if ! wait_for_guest_state "$name shortcut opens" 15 guest_layer_present "$namespace"; then
|
|
capture_console "failure-shortcut-$name-open"
|
|
ssh_session "omarchy-shell shell hide '$plugin' >/dev/null 2>&1 || true" || true
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-$name"
|
|
|
|
press esc
|
|
if ! wait_for_guest_state "$name shortcut surface closes" 15 guest_layer_absent "$namespace"; then
|
|
capture_console "failure-shortcut-$name-close"
|
|
ssh_session "omarchy-shell shell hide '$plugin' >/dev/null 2>&1 || true" || true
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
verify_desktop_shortcuts() {
|
|
cleanup_shortcut_state
|
|
|
|
press meta_l-ret
|
|
if ! wait_for_guest_state "terminal shortcut opens a window" 30 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
|
|
capture_console "failure-shortcut-terminal-open"
|
|
return 1
|
|
fi
|
|
|
|
press meta_l-ret
|
|
if ! wait_for_guest_state "terminal shortcut opens a second window" 30 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 2'"; then
|
|
capture_console "failure-shortcut-terminal-second-window"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-desktop-01-two-tiled-terminals"
|
|
|
|
press meta_l-t
|
|
if ! wait_for_guest_state "floating shortcut floats the active window" 15 \
|
|
ssh_session "hyprctl -j activewindow | jq -e '.class == \"foot\" and .floating == true'"; then
|
|
capture_console "failure-shortcut-window-float"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-desktop-02-floating-terminal"
|
|
|
|
press meta_l-t
|
|
if ! wait_for_guest_state "floating shortcut returns the window to tiling" 15 \
|
|
ssh_session "hyprctl -j activewindow | jq -e '.class == \"foot\" and .floating == false'"; then
|
|
capture_console "failure-shortcut-window-retile"
|
|
return 1
|
|
fi
|
|
|
|
press meta_l-shift-2
|
|
if ! wait_for_guest_state "move shortcut follows the window to workspace 2" 15 \
|
|
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 2' && \
|
|
hyprctl -j activewindow | jq -e '.class == \"foot\" and .workspace.id == 2'"; then
|
|
capture_console "failure-shortcut-window-workspace-2"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-desktop-03-terminal-on-workspace-2"
|
|
|
|
press meta_l-1
|
|
if ! wait_for_guest_state "workspace shortcut returns to workspace 1" 15 \
|
|
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 1'"; then
|
|
capture_console "failure-shortcut-workspace-1"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-desktop-04-workspace-1"
|
|
|
|
press meta_l-w
|
|
if ! wait_for_guest_state "close shortcut closes the workspace 1 terminal" 15 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
|
|
capture_console "failure-shortcut-close-workspace-1"
|
|
return 1
|
|
fi
|
|
|
|
press meta_l-2
|
|
if ! wait_for_guest_state "workspace shortcut visits workspace 2" 15 \
|
|
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 2'"; then
|
|
capture_console "failure-shortcut-visit-workspace-2"
|
|
return 1
|
|
fi
|
|
press meta_l-w
|
|
if ! wait_for_guest_state "close shortcut closes the workspace 2 terminal" 15 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 0'"; then
|
|
capture_console "failure-shortcut-close-workspace-2"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
verify_universal_clipboard_shortcuts() {
|
|
local copy_token="OmarchyUniversalCopy$(date +%s)"
|
|
local paste_token="OmarchyUniversalPaste$(date +%s)"
|
|
local terminal_token="OmarchyTerminalPaste$(date +%s)"
|
|
local terminal_result="/tmp/omarchy-universal-terminal-paste"
|
|
|
|
cleanup_shortcut_state
|
|
ssh_session "setsid -f chromium --new-window about:blank >/dev/null 2>&1"
|
|
if ! wait_for_guest_state "universal clipboard test opens Chromium" 45 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class | test(\"(?i)chromium\"))] | length == 1'"; then
|
|
capture_console "failure-shortcut-universal-clipboard-browser"
|
|
return 1
|
|
fi
|
|
|
|
press ctrl-l
|
|
type_text "$copy_token"
|
|
press ctrl-a
|
|
press meta_l-c
|
|
if ! wait_for_guest_state "universal copy uses the active GUI application" 15 \
|
|
ssh_session "[[ \$(wl-paste --no-newline) == '$copy_token' ]]"; then
|
|
capture_console "failure-shortcut-universal-copy"
|
|
return 1
|
|
fi
|
|
|
|
ssh_session "setsid -f bash -c 'printf \"%s\" \"$paste_token\" | wl-copy' >/dev/null 2>&1"
|
|
if ! wait_for_guest_state "universal paste clipboard is seeded" 15 \
|
|
ssh_session "[[ \$(wl-paste --no-newline) == '$paste_token' ]]"; then
|
|
capture_console "failure-shortcut-universal-paste-seed"
|
|
return 1
|
|
fi
|
|
press ctrl-l
|
|
press meta_l-v
|
|
sleep 1
|
|
press ctrl-a
|
|
press ctrl-c
|
|
if ! wait_for_guest_state "universal paste uses the active GUI application" 15 \
|
|
ssh_session "[[ \$(wl-paste --no-newline) == '$paste_token' ]]"; then
|
|
capture_console "failure-shortcut-universal-paste-gui"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-universal-copy-paste-gui"
|
|
ssh_session 'while read -r address; do
|
|
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null 2>&1 ||
|
|
hyprctl dispatch closewindow "address:$address" >/dev/null 2>&1 || true
|
|
done < <(hyprctl -j clients | jq -r '\''.[] | select(.class | test("(?i)chromium")) | .address'\'')' || true
|
|
|
|
ssh_session "rm -f '$terminal_result'; \
|
|
setsid -f bash -c 'printf \"%s\" \"$terminal_token\" | wl-copy' >/dev/null 2>&1; \
|
|
setsid -f foot --app-id=foot -e bash -c 'read -r value; printf \"%s\" \"\$value\" > \"$terminal_result\"; sleep 30' >/dev/null 2>&1"
|
|
if ! wait_for_guest_state "terminal paste clipboard is seeded" 15 \
|
|
ssh_session "[[ \$(wl-paste --no-newline) == '$terminal_token' ]]"; then
|
|
capture_console "failure-shortcut-universal-paste-terminal-seed"
|
|
return 1
|
|
fi
|
|
if ! wait_for_guest_state "universal clipboard test opens a terminal" 30 \
|
|
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
|
|
capture_console "failure-shortcut-universal-clipboard-terminal"
|
|
return 1
|
|
fi
|
|
|
|
press meta_l-v
|
|
sleep 1
|
|
press ret
|
|
if ! wait_for_guest_state "universal paste uses the terminal-safe shortcut" 15 \
|
|
ssh_session "[[ \$(cat '$terminal_result' 2>/dev/null) == '$terminal_token' ]]"; then
|
|
capture_console "failure-shortcut-universal-paste-terminal"
|
|
return 1
|
|
fi
|
|
capture_console "success-shortcut-universal-paste-terminal"
|
|
cleanup_shortcut_state
|
|
}
|
|
|
|
shortcut_smoke_phase() {
|
|
log "Exercising primary desktop shortcuts through the VM keyboard"
|
|
|
|
local status=0
|
|
local name chord namespace plugin
|
|
local shortcuts='launcher|meta_l-spc|omarchy-launcher|omarchy.launcher
|
|
menu|meta_l-alt-spc|omarchy-menu|omarchy.menu
|
|
emojis|meta_l-ctrl-e|omarchy-emojis|omarchy.emojis
|
|
clipboard|meta_l-ctrl-v|omarchy-clipboard|omarchy.clipboard
|
|
audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
|
|
|
|
cleanup_shortcut_state
|
|
while IFS='|' read -r name chord namespace plugin; do
|
|
if ! verify_surface_shortcut "$name" "$chord" "$namespace" "$plugin"; then
|
|
status=1
|
|
fi
|
|
done <<<"$shortcuts"
|
|
|
|
if ! verify_desktop_shortcuts; then
|
|
status=1
|
|
fi
|
|
if ! verify_universal_clipboard_shortcuts; then
|
|
status=1
|
|
fi
|
|
cleanup_shortcut_state
|
|
|
|
return $status
|
|
}
|
|
|
|
# ----------------------------------------------------- driving the installer
|
|
|
|
drive_configurator() {
|
|
log "Driving the installer wizard"
|
|
|
|
# A greeter (animated logo + tagline) opens the installer; Return begins.
|
|
wait_for_screen "Opinionated" 300
|
|
capture_console "success-installer-00-greeter"
|
|
press ret
|
|
|
|
# Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning
|
|
# mode; a normal install just selects the layout.
|
|
wait_for_screen "keyboard layout" 300
|
|
capture_console "success-installer-01-keyboard-layout"
|
|
|
|
if $PROVISION; then
|
|
press ctrl-c # arm "prepare for another owner"
|
|
wait_for_screen "another owner" 30
|
|
capture_console "success-installer-02-prepare-confirm"
|
|
press ret # "Yes, prepare for another owner" is the affirmative default
|
|
|
|
# Jumps straight to disk selection, then the overwrite confirm.
|
|
wait_for_screen "install disk" 60
|
|
capture_console "success-installer-03-prepare-install-disk"
|
|
press ret
|
|
|
|
wait_for_screen "recovery possible" 60
|
|
capture_console "success-installer-04-prepare-disk-warning-encrypted"
|
|
if ! $ENCRYPT; then
|
|
press ctrl-c
|
|
wait_for_screen "without encryption" 30
|
|
capture_console "success-installer-05-prepare-disk-warning-unencrypted"
|
|
fi
|
|
press ret
|
|
sleep 2
|
|
capture_console "success-installer-13-install-started"
|
|
return 0
|
|
fi
|
|
|
|
press ret # English (US) is preselected
|
|
|
|
# Normal flow: user step comes before disk selection.
|
|
wait_for_screen "Username" 120
|
|
type_text "$GUEST_USER"
|
|
capture_console "success-installer-02-username"
|
|
press ret
|
|
|
|
wait_for_screen "Password" 60
|
|
type_text "$GUEST_PASSWORD"
|
|
capture_console "success-installer-03-password"
|
|
press ret
|
|
|
|
wait_for_screen "Must match" 60
|
|
type_text "$GUEST_PASSWORD"
|
|
capture_console "success-installer-04-password-confirmation"
|
|
press ret
|
|
|
|
wait_for_screen "Full name" 60
|
|
type_text "Omarchy Test"
|
|
capture_console "success-installer-05-full-name"
|
|
press ret
|
|
|
|
wait_for_screen "Email address" 60
|
|
type_text "test@omarchy.org"
|
|
capture_console "success-installer-06-email"
|
|
press ret
|
|
|
|
wait_for_screen "Hostname" 60
|
|
type_text "$GUEST_HOSTNAME"
|
|
capture_console "success-installer-07-hostname"
|
|
press ret
|
|
|
|
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
|
|
capture_console "success-installer-08-timezone"
|
|
press ret
|
|
|
|
wait_for_screen "look right" 60
|
|
capture_console "success-installer-09-review"
|
|
press ret
|
|
|
|
wait_for_screen "install disk" 60
|
|
capture_console "success-installer-10-install-disk"
|
|
press ret # the single virtio disk
|
|
|
|
wait_for_screen "installation mode\|recovery possible" 60
|
|
if ocr_screen | grep -qi "installation mode"; then
|
|
capture_console "success-installer-11-install-mode"
|
|
press ret # "Full disk install" is preselected
|
|
fi
|
|
|
|
wait_for_screen "recovery possible" 60
|
|
capture_console "success-installer-12-disk-warning-encrypted"
|
|
if ! $ENCRYPT; then
|
|
press ctrl-c # toggles the confirm to the unencrypted flow
|
|
wait_for_screen "without encryption" 30
|
|
capture_console "success-installer-13-disk-warning-unencrypted"
|
|
fi
|
|
press ret
|
|
|
|
sleep 2
|
|
capture_console "success-installer-14-install-started"
|
|
}
|
|
|
|
# The deferred-provisioning first boot runs the user form on tty1 (omarchy-provision-owner) before
|
|
# SDDM. Encrypted deferred-provisioning installs auto-unlock from the staged keyfile, so there
|
|
# is no LUKS prompt to answer here either way.
|
|
drive_provision_owner() {
|
|
log "Driving the deferred-provisioning first-boot setup"
|
|
|
|
# Deferred provisioning defers the keyboard step to first boot; it comes before the user form.
|
|
# Pick English (UK) (one row above the preselected English (US)) so the
|
|
# verifier can prove the deferred step applied a non-default layout — UK
|
|
# leaves every letter identical to US, so the typed password (sent as raw
|
|
# keycodes) still matches the literal one the harness pipes to sudo.
|
|
# A greeter (animated logo + tagline) opens first boot; Return begins.
|
|
wait_for_screen "Opinionated" 600
|
|
capture_console "success-provision-00-greeter"
|
|
press ret
|
|
|
|
wait_for_screen "keyboard layout" 120
|
|
press up
|
|
capture_console "success-provision-01-keyboard-layout-uk"
|
|
press ret
|
|
|
|
wait_for_screen "Username" 120
|
|
capture_console "success-provision-02-username"
|
|
type_text "$GUEST_USER"
|
|
press ret
|
|
|
|
wait_for_screen "Password" 60
|
|
type_text "$GUEST_PASSWORD"
|
|
capture_console "success-provision-03-password"
|
|
press ret
|
|
|
|
wait_for_screen "Conf *irm" 60
|
|
type_text "$GUEST_PASSWORD"
|
|
press ret
|
|
|
|
wait_for_screen "Full name" 60
|
|
type_text "Omarchy Test"
|
|
press ret
|
|
|
|
wait_for_screen "Email address" 60
|
|
# The keyboard step above applied English (UK), where '@' is Shift+' (not
|
|
# Shift+2 as on US); send that keycode so the typed email is correct under
|
|
# the new layout — itself further proof the deferred keymap took effect.
|
|
type_text "test"
|
|
press shift-apostrophe
|
|
type_text "omarchy.org"
|
|
capture_console "success-provision-04-email"
|
|
press ret
|
|
|
|
# Hostname is deferred to first boot too.
|
|
wait_for_screen "Hostname" 60
|
|
type_text "$GUEST_HOSTNAME"
|
|
capture_console "success-provision-05-hostname"
|
|
press ret
|
|
|
|
# Timezone is deferred to first boot too; accept the geo-guessed default.
|
|
# Generous timeout: the tzupdate geo-guess over first-boot networking can lag.
|
|
wait_for_screen "Timezone" 180
|
|
capture_console "success-provision-06-timezone"
|
|
press ret
|
|
|
|
wait_for_screen "look right" 60
|
|
capture_console "success-provision-07-review"
|
|
press ret
|
|
|
|
# Finalization (offline Node unpack, LUKS re-key on encrypted installs)
|
|
# runs before SDDM appears.
|
|
capture_console "success-provision-08-finalizing"
|
|
}
|
|
|
|
wait_for_install() {
|
|
log "Waiting for the install to finish (timeout ${INSTALL_TIMEOUT}s)"
|
|
|
|
local waited=0 text progress_name
|
|
while true; do
|
|
text=$(ocr_screen)
|
|
|
|
if grep -qi "Reboot Now" <<<"$text"; then
|
|
log "Install finished. Confirming the reboot prompt."
|
|
capture_console "success-installer-15-reboot"
|
|
press ret
|
|
return 0
|
|
fi
|
|
|
|
# Deferred-provisioning installs skip the celebration and reboot on their own
|
|
# (no Reboot Now prompt); the first-boot greeter tagline appearing means the
|
|
# install finished and rebooted into setup.
|
|
if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
|
|
log "Install finished and auto-rebooted into first-boot setup."
|
|
capture_console "success-installer-15-autoreboot"
|
|
return 0
|
|
fi
|
|
|
|
if grep -qi "installation stopped" <<<"$text"; then
|
|
capture_console "failure-installer-installation-stopped"
|
|
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ! vm_running; then
|
|
echo "VM exited during install" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ((waited >= INSTALL_TIMEOUT)); then
|
|
capture_console "failure-installer-install-timeout"
|
|
echo "Timed out after ${INSTALL_TIMEOUT}s waiting for install" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ((waited % 60 == 0)); then
|
|
printf -v progress_name 'success-installer-14-install-progress-%04ds' "$waited"
|
|
capture_console "$progress_name"
|
|
echo " ... installing (${waited}s)"
|
|
fi
|
|
|
|
sleep 10
|
|
((waited += 10))
|
|
done
|
|
}
|
|
|
|
# Encrypted boots stop at the LUKS passphrase prompt. Plymouth's prompt isn't
|
|
# reliably OCR-able, so type the passphrase on an interval until a console
|
|
# login becomes reachable; early keystrokes are harmlessly discarded.
|
|
unlock_luks() {
|
|
log "Typing the LUKS passphrase until boot proceeds"
|
|
|
|
local waited=0
|
|
sleep 10
|
|
capture_console "success-first-boot-01-luks-prompt"
|
|
|
|
while true; do
|
|
type_text "$GUEST_PASSWORD"
|
|
if ((waited == 0)); then
|
|
capture_console "success-first-boot-02-luks-passphrase"
|
|
fi
|
|
press ret
|
|
sleep 20
|
|
((waited += 30))
|
|
|
|
press ctrl-alt-f3
|
|
sleep 3
|
|
if ocr_screen | grep -qi "login:"; then
|
|
return 0
|
|
fi
|
|
|
|
if ((waited >= 300)); then
|
|
capture_console "failure-first-boot-luks-timeout"
|
|
echo "Timed out waiting for LUKS unlock" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Log into a spare console TTY as the user and authorize the harness SSH key —
|
|
# the same thing a person would do to give themselves remote access.
|
|
bootstrap_ssh() {
|
|
log "Authorizing SSH access via console login"
|
|
|
|
mkdir -p "$BASE_DIR/www"
|
|
cat >"$BASE_DIR/www/bootstrap" <<EOF
|
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
|
echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys
|
|
chmod 600 ~/.ssh/authorized_keys
|
|
echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp
|
|
echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service
|
|
EOF
|
|
|
|
(cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
|
|
HTTP_PID=$!
|
|
|
|
local waited=0
|
|
while true; do
|
|
press ctrl-alt-f3
|
|
sleep 4
|
|
ocr_screen | grep -qi "login:" && break
|
|
((waited += 8))
|
|
|
|
if ((waited >= 300)); then
|
|
capture_console "failure-first-boot-console-timeout"
|
|
echo "Timed out waiting for a console login prompt" >&2
|
|
return 1
|
|
fi
|
|
sleep 4
|
|
done
|
|
|
|
type_text "$GUEST_USER"
|
|
capture_console "success-first-boot-03-console-username"
|
|
press ret
|
|
wait_for_screen "Password" 60
|
|
type_text "$GUEST_PASSWORD"
|
|
capture_console "success-first-boot-04-console-password"
|
|
press ret
|
|
sleep 3
|
|
|
|
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs"
|
|
capture_console "success-first-boot-05-bootstrap-command"
|
|
press ret
|
|
|
|
wait_for_ssh 360 "failure-first-boot-ssh-timeout"
|
|
capture_console "success-first-boot-06-bootstrap-complete"
|
|
|
|
kill "$HTTP_PID" 2>/dev/null || true
|
|
HTTP_PID=""
|
|
press ctrl-alt-f1
|
|
}
|
|
|
|
# Get a freshly booted system to a running Hyprland session, typing at the
|
|
# LUKS prompt and/or SDDM greeter exactly like a user would.
|
|
establish_session() {
|
|
if $ENCRYPT; then
|
|
local waited=0
|
|
sleep 10
|
|
capture_console "success-session-01-luks-prompt"
|
|
until ssh_guest true 2>/dev/null; do
|
|
type_text "$GUEST_PASSWORD"
|
|
if ((waited == 0)); then
|
|
capture_console "success-session-02-luks-passphrase"
|
|
fi
|
|
press ret
|
|
sleep 15
|
|
((waited += 16))
|
|
|
|
if ((waited >= BOOT_TIMEOUT)); then
|
|
capture_console "failure-session-luks-timeout"
|
|
echo "Timed out unlocking LUKS" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
# Encrypted installs autologin once the disk is unlocked
|
|
else
|
|
wait_for_ssh "$BOOT_TIMEOUT" "failure-session-ssh-timeout"
|
|
sleep 5
|
|
fi
|
|
|
|
local waited=0
|
|
until session_started; do
|
|
if ! $ENCRYPT; then
|
|
# SDDM remembers the user and focuses the password field
|
|
if ((waited == 0)); then
|
|
capture_console "success-session-01-login"
|
|
fi
|
|
type_text "$GUEST_PASSWORD"
|
|
if ((waited == 0)); then
|
|
capture_console "success-session-02-password"
|
|
fi
|
|
press ret
|
|
fi
|
|
sleep 10
|
|
((waited += 11))
|
|
|
|
if ((waited >= 300)); then
|
|
capture_console "failure-session-start-timeout"
|
|
echo "Timed out waiting for the Hyprland session" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
capture_console "success-session-03-desktop"
|
|
}
|
|
|
|
# -------------------------------------------------------------------- phases
|
|
|
|
install_phase() {
|
|
log "Installing $(basename "$ISO") into a fresh VM (headless, driven via console)"
|
|
|
|
[[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-iso-test" -f "$SSH_KEY"
|
|
|
|
rm -f "$BASE_DISK"
|
|
qemu-img create -f qcow2 "$BASE_DISK" 40G >/dev/null
|
|
cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF"
|
|
|
|
start_vm "$BASE_DISK" "$RUN_DIR/install-serial.log" \
|
|
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \
|
|
-device ide-cd,drive=cdrom0,bootindex=2
|
|
|
|
drive_configurator
|
|
wait_for_install
|
|
|
|
if $PROVISION; then
|
|
drive_provision_owner
|
|
elif $ENCRYPT; then
|
|
unlock_luks
|
|
fi
|
|
bootstrap_ssh
|
|
|
|
# SSH can come up (console bootstrap logs in on a spare TTY) while
|
|
# omarchy-provision-owner is still finalizing the user and re-keying LUKS. Saving
|
|
# the base mid-finalization produces a flaky image, so wait for the pending
|
|
# flag to clear before powering off.
|
|
if $PROVISION; then
|
|
log "Waiting for provisioning first-boot setup to finish"
|
|
local waited=0 rc
|
|
while true; do
|
|
# Capture rc via `|| rc=$?` so a non-zero result doesn't trip `set -e`
|
|
# (this command is intentionally allowed to "fail" — that's the signal).
|
|
rc=0
|
|
ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$?
|
|
# rc 0 = flag still present; rc 255 = SSH not up yet (finalization may
|
|
# still be running) — keep waiting. Only a clean "file absent" (rc 1)
|
|
# means provisioning finished, so we don't snapshot mid-finalization on a
|
|
# transient SSH blip.
|
|
(( rc == 0 || rc == 255 )) || break
|
|
if ((waited >= 900)); then
|
|
capture_console "failure-provision-finalization-timeout"
|
|
echo "Timed out waiting for provisioning first-boot setup to complete" >&2
|
|
return 1
|
|
fi
|
|
sleep 10
|
|
((waited += 10))
|
|
done
|
|
|
|
# The owner's hostname and timezone are set at first boot now, not baked
|
|
# into the install — confirm they actually landed on the running system.
|
|
# Hostname is deterministic (we typed it), so a mismatch is a hard failure;
|
|
# timezone is geo-guessed, so just report what landed.
|
|
local got_host="" got_tz=""
|
|
got_host=$(ssh_guest "cat /etc/hostname" 2>/dev/null | tr -d '\r\n') || got_host=""
|
|
got_tz=$(ssh_guest "timedatectl show -p Timezone --value" 2>/dev/null | tr -d '\r\n') || got_tz=""
|
|
if [[ $got_host != "$GUEST_HOSTNAME" ]]; then
|
|
capture_console "failure-provision-hostname-not-applied"
|
|
echo "First-boot hostname is '${got_host:-<empty>}', expected '$GUEST_HOSTNAME'" >&2
|
|
return 1
|
|
fi
|
|
log "hostname applied at first boot: $got_host"
|
|
if [[ -n $got_tz && $got_tz != "UTC" ]]; then
|
|
log "timezone applied at first boot: $got_tz"
|
|
else
|
|
echo "WARN: timezone is '${got_tz:-<empty>}' (expected the owner's choice, not UTC)" >&2
|
|
fi
|
|
|
|
# Deferred first-boot setup hands straight off to SDDM once the oneshot
|
|
# service exits — no celebration screen, no Start Omarchy button to confirm.
|
|
capture_console "success-provision-09-handoff"
|
|
fi
|
|
|
|
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \
|
|
>"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true
|
|
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
|
|
>"$RUN_DIR/pacman.log" 2>/dev/null || true
|
|
|
|
log "Installed system is up. Saving base image."
|
|
stop_vm
|
|
}
|
|
|
|
sync_omarchy() {
|
|
[[ -n $SYNC_DIR ]] || return 0
|
|
|
|
local target=".local/share/omarchy"
|
|
|
|
if $SYNC_ALL; then
|
|
log "Syncing $SYNC_DIR into the guest"
|
|
tar -C "$SYNC_DIR" --exclude .git -cf - . | ssh_guest "mkdir -p $target && tar -C $target -xf -"
|
|
else
|
|
log "Syncing $SYNC_DIR/test/acceptance into the guest"
|
|
tar -C "$SYNC_DIR/test" -cf - acceptance acceptance.d | ssh_guest "mkdir -p $target/test && tar -C $target/test -xf -"
|
|
fi
|
|
}
|
|
|
|
acceptance_phase() {
|
|
log "Booting acceptance VM from base image overlay"
|
|
|
|
local guest_omarchy_path="/usr/share/omarchy"
|
|
$SYNC_ALL && guest_omarchy_path=".local/share/omarchy"
|
|
|
|
qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null
|
|
start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log"
|
|
|
|
establish_session
|
|
sync_omarchy
|
|
|
|
local status=0
|
|
shortcut_smoke_phase || status=1
|
|
|
|
log "Running acceptance suite"
|
|
ssh_guest "OMARCHY_PATH=$guest_omarchy_path OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance bash .local/share/omarchy/test/acceptance" || status=$?
|
|
|
|
log "Collecting artifacts into $RUN_DIR"
|
|
ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true
|
|
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true
|
|
if ((status == 0)); then
|
|
capture_console "success-acceptance-final"
|
|
else
|
|
capture_console "failure-acceptance-final"
|
|
fi
|
|
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------- main
|
|
|
|
if $REUSE_BASE && [[ -f $BASE_DISK ]]; then
|
|
log "Reusing existing base image: $BASE_DISK"
|
|
else
|
|
install_phase
|
|
fi
|
|
|
|
if $INSTALL_ONLY; then
|
|
log "Base image ready: $BASE_DISK"
|
|
exit 0
|
|
fi
|
|
|
|
if acceptance_phase; then
|
|
log "Acceptance suite passed. Artifacts: $RUN_DIR"
|
|
else
|
|
log "Acceptance suite FAILED. Artifacts: $RUN_DIR"
|
|
exit 1
|
|
fi
|