From c33736775b237072ff6b9013140a02103b11c8d4 Mon Sep 17 00:00:00 2001 From: David Young Date: Wed, 4 Nov 2026 09:43:05 -0700 Subject: [PATCH] first commit --- Makefile | 13 ++ README.md | 91 +++++++++++ bin/coredesktop-dev | 46 ++++++ hooks/pre-push | 18 +++ install.sh | 77 +++++++++ lib/check.sh | 63 ++++++++ lib/common.sh | 67 ++++++++ lib/deployed.sh | 79 +++++++++ lib/doctor.sh | 113 +++++++++++++ lib/hooks.sh | 70 ++++++++ lib/smoketest.sh | 353 +++++++++++++++++++++++++++++++++++++++++ lib/try.sh | 71 +++++++++ lib/vm.sh | 182 +++++++++++++++++++++ tests/check.bats | 59 +++++++ tests/deployed.bats | 56 +++++++ tests/doctor.bats | 73 +++++++++ tests/hooks.bats | 66 ++++++++ tests/install.bats | 81 ++++++++++ tests/smoketest.bats | 169 ++++++++++++++++++++ tests/test_helper.bash | 92 +++++++++++ tests/try.bats | 47 ++++++ tests/update.bats | 51 ++++++ update.sh | 41 +++++ vm/provision.sh | 61 +++++++ vm/user-data | 17 ++ 25 files changed, 2056 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100755 bin/coredesktop-dev create mode 100755 hooks/pre-push create mode 100644 install.sh create mode 100644 lib/check.sh create mode 100644 lib/common.sh create mode 100644 lib/deployed.sh create mode 100644 lib/doctor.sh create mode 100644 lib/hooks.sh create mode 100644 lib/smoketest.sh create mode 100644 lib/try.sh create mode 100644 lib/vm.sh create mode 100644 tests/check.bats create mode 100644 tests/deployed.bats create mode 100644 tests/doctor.bats create mode 100644 tests/hooks.bats create mode 100644 tests/install.bats create mode 100644 tests/smoketest.bats create mode 100644 tests/test_helper.bash create mode 100644 tests/try.bats create mode 100644 tests/update.bats create mode 100644 update.sh create mode 100644 vm/provision.sh create mode 100644 vm/user-data diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6899c92 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +# Every tracked file (not symlink) that starts with a bash or sh shebang (\043 is "#", which +# make would otherwise read as the start of a comment) +SCRIPTS := $(shell git ls-files -s | awk '$$1 != 120000 { print $$4 }' | xargs awk 'FNR == 1 && /^#!.*[\/ ](ba)?sh/ { print FILENAME }') + +.PHONY: check lint test + +check: lint test + +lint: + shellcheck --severity=warning $(SCRIPTS) + +test: + bats tests diff --git a/README.md b/README.md new file mode 100644 index 0000000..37cbf97 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# coredesktop-dev + +Tools for working on [CoreDesktop](https://gitea.federated.computer/Federated_Computer/coredesktop) +itself. Only contributors need these, so they're a CoreDesktop sibling project +rather than part of CoreDesktop. + +## Install + +``` +sibling add +``` + +It asks where your working copy of CoreDesktop is (default +`~/Projects/coredesktop`), and offers to clone it there. CoreDesktop's `update` +keeps this project current from then on. To change the working copy, run +`sibling add` again. + +It installs shellcheck, bats, tea (Gitea's CLI), QEMU and xorriso. + +## Why: the two copies of CoreDesktop + +A developer's machine has two copies of CoreDesktop: + +- **Deployed**, in `~/.local/share/coredesktop`: what the machine runs, and + what `update` pulls (fast-forward only). Never edit it: a changed tracked + file stops `update` from pulling. +- **Working**, e.g. `~/Projects/coredesktop`: where you make changes. Nothing + runs from here unless you run it. + +These commands let you test the working copy without touching the deployed one. + +## Commands + +| Command | | +|---------|---| +| `coredesktop-dev doctor` | Check the tools, the working copy, and that the deployed copy can still update | +| `coredesktop-dev check [--fast]` | `make lint` and `make test` in the working copy, plus shellcheck on scripts not committed yet. `--fast`: shellcheck only | +| `coredesktop-dev try [--keep] [cmd...]` | Run the working copy's code as if it were installed, in a throwaway home with its own D-Bus session and no display, e.g. `coredesktop-dev try configure --list`. sudo, yay, pacman and systemctl still change this machine | +| `coredesktop-dev deployed [status\|restore]` | Show the deployed copy's state, or undo local changes to it (after asking) | +| `coredesktop-dev hooks [status\|install\|remove]` | A pre-push hook in the working copy that runs `check` first. `git push --no-verify` skips it | +| `coredesktop-dev smoketest ...` | Test install and update on virtual machines (below) | + +Each takes `--help`. In a Flatpak (an editor's built-in terminal, say), +coredesktop-dev runs itself on the host with `flatpak-spawn --host`. + +## Smoketest + +Runs CoreDesktop's end-to-end scenarios, in `smoketest/` in the working copy, +on throwaway QEMU virtual machines: a fresh install, updates of all kinds, +failing migrations and hooks, low disk space, a month-old machine, and more. +See `smoketest/README.md` in CoreDesktop for what's tested and how to add a +scenario. + +``` +coredesktop-dev smoketest list # the scenarios +coredesktop-dev smoketest run # the quick ones (tier pr), against HEAD +coredesktop-dev smoketest run --tier all # everything; hours +coredesktop-dev smoketest run 40 43 # just these +coredesktop-dev smoketest run --keep 40 # on failure, leave the VM up to look around +coredesktop-dev smoketest shell installed # SSH into a throwaway VM +``` + +What it needs: KVM (virtualization turned on in the firmware), about 8 GB of +RAM and 4 CPUs per VM (`SMOKETEST_MEMORY`, `SMOKETEST_CPUS`), and disk space in +`~/.cache/coredesktop-dev/smoketest`: VM disks grow as large as a real install, +about 50 GB, and each run's VM disk is deleted when it's done. + +The first run builds two images, which takes a while: + +- **base**: Arch's official cloud image, with GNOME added the way archinstall's + desktop profile installs it. The user `tester` (password `tester`) logs in to + GNOME automatically and has passwordless sudo. +- **installed**: base after CoreDesktop's `install.sh`, at the commit your + branch started from. Scenarios on this image first `update` it to the commit + under test. Rebuild it now and then (`smoketest images installed`) so that + update has less to catch up on. + +Downloaded Arch packages are kept in the cache folder and shared with every +VM, so they download once. Flatpaks aren't cached. + +To watch a VM's screen, set `SMOKETEST_VNC=1` and connect a VNC viewer to +`127.0.0.1:5901`. + +## Developing this project + +``` +make check # shellcheck + bats +``` + +The tests use fake commands, like CoreDesktop's: nothing touches the machine, +and the smoketest tests use a fake QEMU. diff --git a/bin/coredesktop-dev b/bin/coredesktop-dev new file mode 100755 index 0000000..cb19bd7 --- /dev/null +++ b/bin/coredesktop-dev @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Purpose : Tools for working on CoreDesktop itself (see README.md) + +SELF="$(readlink -f "${BASH_SOURCE[0]}")" +LIB="$(dirname "$SELF")/../lib" + +source "$LIB/common.sh" + +usage() { + cat << EOF +Usage: coredesktop-dev [--help] + + doctor Check this machine is set up to work on CoreDesktop + check Run the linters and tests on the working copy + try Run the working copy's code as if it were installed, in a throwaway home + deployed Show, or restore, the installed copy of CoreDesktop + hooks Install or remove the pre-push hook that runs check + smoketest Test install and update on a virtual machine +EOF +} + +command="${1:-}" +shift + +case "$command" in + doctor | check | try | deployed | hooks | smoketest) ;; + "" | -h | --help | help) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; +esac + +# In a Flatpak (an editor's terminal, say), pacman, systemctl, qemu and the +# host's tools aren't there: run on the host instead +if in_flatpak && [[ "${1:-}" != -h && "${1:-}" != --help ]]; then + echo "Running in a Flatpak; running coredesktop-dev $command on the host (flatpak-spawn --host)." >&2 + exec flatpak-spawn --host --env=COREDESKTOP_DEV_VIA_FLATPAK=1 "$SELF" "$command" "$@" +fi + +source "$LIB/$command.sh" +"cmd_$command" "$@" diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..0abb476 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,18 @@ +#!/bin/bash +# Installed by coredesktop-dev; remove with: coredesktop-dev hooks remove +# +# Runs coredesktop-dev check before git push, and stops the push if it fails. + +CHECK="$HOME/.local/bin/coredesktop-dev" + +if [ ! -x "$CHECK" ]; then + echo "pre-push: coredesktop-dev isn't installed, so the checks didn't run." + exit 0 +fi + +if ! "$CHECK" check; then + echo + echo "pre-push: the checks failed, so nothing was pushed." + echo "To push anyway: git push --no-verify" + exit 1 +fi diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..274edb4 --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# +# Purpose : Install coredesktop-dev (run by CoreDesktop's `sibling add`). Safe to +# re-run: that's how to change the working copy or repair the install. + +set -e + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COREDESKTOP="$HOME/.local/share/coredesktop" + +if [ ! -f "$COREDESKTOP/utils.sh" ]; then + echo "CoreDesktop isn't installed in $COREDESKTOP; install it first." + exit 1 +fi +source "$COREDESKTOP/utils.sh" +source "$SELF/lib/common.sh" + +load_config +default="${WORKING_COPY:-$HOME/Projects/coredesktop}" + +# Ask until the answer is a CoreDesktop clone, or a folder to clone one into +while true; do + if ! read -rp "Your working copy of CoreDesktop, where you make changes [$default]: " answer; then + echo + die "No answer; run this again in a terminal: bash $SELF/install.sh" + fi + dir="${answer:-$default}" + dir="${dir/#\~/$HOME}" + dir="$(realpath -m "$dir")" + + if is_deployed_copy "$dir"; then + echo "$dir is the deployed copy, which update keeps current. Changing it stops" + echo "update from pulling. Choose another folder, e.g. ~/Projects/coredesktop." + continue + fi + + if is_coredesktop_clone "$dir"; then + origin="$(git -C "$dir" remote get-url origin 2> /dev/null || true)" + if [[ "$origin" != *coredesktop* ]]; then + echo "Note: $dir's origin ($origin) doesn't look like CoreDesktop's repo." + fi + break + fi + + if [ -e "$dir" ] && [ -n "$(ls -A "$dir" 2> /dev/null)" ]; then + echo "$dir isn't a clone of CoreDesktop, and isn't empty. Choose another folder." + continue + fi + + url="$(git -C "$COREDESKTOP" remote get-url origin)" + read -rp "Clone CoreDesktop from $url into $dir? [Y/n] " answer || answer=n + if [[ "$answer" != [nN]* ]]; then + git clone "$url" "$dir" + break + fi +done + +save_config "$dir" +WORKING_COPY="$dir" +echo "Working copy: $dir (saved in $CONFIG)" + +bash "$SELF/update.sh" + +source "$SELF/lib/hooks.sh" +if ! hook_is_ours "$(hook_path)"; then + echo + echo "A pre-push hook can run coredesktop-dev check before each git push from" + echo "$dir, and stop the push if it fails." + if confirm "Install it?"; then + "$HOME/.local/bin/coredesktop-dev" hooks install || true + fi +fi + +echo +"$HOME/.local/bin/coredesktop-dev" doctor || true +echo +echo "coredesktop-dev installed. Run coredesktop-dev --help to see what it does." diff --git a/lib/check.sh b/lib/check.sh new file mode 100644 index 0000000..f946d54 --- /dev/null +++ b/lib/check.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev check: lint and test the working copy, including +# scripts not committed yet, which CoreDesktop's make lint skips + +check_usage() { + cat << EOF +Usage: coredesktop-dev check [--fast] + +Runs make lint and make test in the working copy, then shellcheck on the +scripts git doesn't track yet. --fast runs only the shellcheck steps. +EOF +} + +# Print each file named on stdin (NUL-separated) that starts with a bash or sh +# shebang, as the Makefile picks them, or is a .sh file +shell_scripts() { + local file + while IFS= read -r -d '' file; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + if [[ "$file" == *.sh ]] || head -n 1 "$file" | grep -qE '^#!.*[/ ](ba)?sh'; then + echo "$file" + fi + done +} + +cmd_check() { + local fast=0 + case "${1:-}" in + -h | --help) check_usage; return 0 ;; + --fast) fast=1 ;; + "") ;; + *) check_usage >&2; return 1 ;; + esac + require_working_copy + cd "$WORKING_COPY" || return 1 + + local status=0 tracked untracked lint="ok" bats="skipped (--fast)" + mapfile -t tracked < <(git ls-files -z | shell_scripts) + mapfile -t untracked < <(git ls-files -z --others --exclude-standard | shell_scripts) + + echo "== make lint" + make --no-print-directory lint || { lint="FAILED"; status=1; } + + if (( ${#untracked[@]} > 0 )); then + echo "== shellcheck on untracked scripts: ${untracked[*]}" + shellcheck --severity=warning "${untracked[@]}" || { lint="FAILED"; status=1; } + fi + + if (( fast == 0 )); then + echo "== make test" + if make --no-print-directory test; then + bats="ok ($(bats --count tests 2> /dev/null) tests)" + else + bats="FAILED" + status=1 + fi + fi + + echo + echo "shellcheck: $lint (${#tracked[@]} files, ${#untracked[@]} untracked) bats: $bats" + return $status +} diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..d508e80 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# Purpose : Paths, settings and helpers shared by coredesktop-dev's scripts + +# The deployed copy: the machine's running CoreDesktop, which update pulls +COREDESKTOP="$HOME/.local/share/coredesktop" + +CONFIG_DIR="$HOME/.config/coredesktop-dev" +CONFIG="$CONFIG_DIR/config" + +# First line after the shebang of the pre-push hook this project installs, so it +# never overwrites or removes a hook someone else wrote +HOOK_MARKER="# Installed by coredesktop-dev; remove with: coredesktop-dev hooks remove" + +die() { + echo "$*" >&2 + exit 1 +} + +confirm() { + local answer + read -rp "$1 [y/N] " answer + [[ "$answer" == [yY]* ]] +} + +# Sets WORKING_COPY from the settings file (empty if there is none) +load_config() { + WORKING_COPY="" + # shellcheck source=/dev/null + [ -f "$CONFIG" ] && source "$CONFIG" + return 0 +} + +save_config() { + mkdir -p "$CONFIG_DIR" + printf 'WORKING_COPY=%q\n' "$1" > "$CONFIG" +} + +# True if DIR is the deployed copy, however it's spelled +is_deployed_copy() { + [ -e "$1" ] && [ "$(realpath "$1")" = "$(realpath "$COREDESKTOP" 2> /dev/null)" ] +} + +# True if DIR is the top of a git clone of CoreDesktop +is_coredesktop_clone() { + [ -d "$1" ] \ + && [ "$(git -C "$1" rev-parse --show-toplevel 2> /dev/null)" = "$(realpath "$1")" ] \ + && [ -f "$1/update" ] && [ -d "$1/updates.d" ] +} + +# Load the settings and stop unless they point at a CoreDesktop clone +require_working_copy() { + load_config + [ -n "$WORKING_COPY" ] \ + || die "No working copy is set. Run $(sibling_dir)/install.sh, or: sibling add " + is_coredesktop_clone "$WORKING_COPY" \ + || die "The working copy, $WORKING_COPY, isn't a clone of CoreDesktop (set in $CONFIG)." +} + +in_flatpak() { + [ -f /.flatpak-info ] || [ -n "${FLATPAK_ID:-}" ] +} + +# This project's folder +sibling_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} diff --git a/lib/deployed.sh b/lib/deployed.sh new file mode 100644 index 0000000..5e1f2ed --- /dev/null +++ b/lib/deployed.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev deployed: show the deployed copy of CoreDesktop +# (~/.local/share/coredesktop), or put back its tracked files so +# update can pull again + +deployed_usage() { + cat << EOF +Usage: coredesktop-dev deployed [status|restore] + + status Branch, commit, how far behind upstream, and local changes (default) + restore Undo local changes to tracked files, after asking. Untracked files + are listed and left alone. +EOF +} + +cmd_deployed() { + case "${1:-status}" in + -h | --help) deployed_usage; return 0 ;; + status | restore) ;; + *) deployed_usage >&2; return 1 ;; + esac + git -C "$COREDESKTOP" rev-parse --git-dir &> /dev/null \ + || die "CoreDesktop isn't installed in $COREDESKTOP." + + "deployed_${1:-status}" +} + +deployed_status() { + local behind changes + echo "Deployed copy: $COREDESKTOP" + echo "Branch: $(git -C "$COREDESKTOP" rev-parse --abbrev-ref HEAD)" + echo "Commit: $(git -C "$COREDESKTOP" log -1 --format='%cd (%h) %s' --date=format:%Y.%m.%d)" + + if GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$COREDESKTOP" fetch --quiet &> /dev/null \ + && behind="$(git -C "$COREDESKTOP" rev-list --count 'HEAD..@{upstream}' 2> /dev/null)"; then + echo "Behind: $behind commits (update pulls them)" + else + echo "Behind: unknown (couldn't reach upstream)" + fi + + changes="$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" + if [ -z "$changes" ]; then + echo "Changes: none" + else + echo "Changes: these stop update from pulling (coredesktop-dev deployed restore):" + echo "$changes" | sed 's/^/ /' + fi +} + +deployed_restore() { + local untracked + if [ -z "$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" ]; then + echo "The deployed copy has no local changes." + return 0 + fi + + git -C "$COREDESKTOP" diff HEAD --stat + untracked="$(git -C "$COREDESKTOP" ls-files --others --exclude-standard)" + if [ -n "$untracked" ]; then + echo + echo "Untracked files, which are left alone:" + echo "$untracked" | sed 's/^/ /' + fi + echo + + if ! confirm "Undo the changes above in $COREDESKTOP?"; then + echo "Nothing changed." + return 1 + fi + git -C "$COREDESKTOP" checkout HEAD -- . || return 1 + + if [ -n "$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" ]; then + echo "Restored, but some changes remain (new files added to git's index?):" + git -C "$COREDESKTOP" status --short --untracked-files=no + return 1 + fi + echo "Restored. update can pull again." +} diff --git a/lib/doctor.sh b/lib/doctor.sh new file mode 100644 index 0000000..812929d --- /dev/null +++ b/lib/doctor.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev doctor: check this machine is set up to work on +# CoreDesktop. Read-only. + +doctor_usage() { + cat << EOF +Usage: coredesktop-dev doctor + +Checks the tools are installed, the working copy is set up, and the deployed +copy (~/.local/share/coredesktop) can still update. Exits 1 if anything needs +fixing. +EOF +} + +PROBLEMS=0 + +ok() { + echo "ok $1" +} + +problem() { + echo "PROBLEM $1" + shift + local line + for line in "$@"; do + echo " $line" + done + PROBLEMS=1 +} + +note() { + echo "note $1" +} + +cmd_doctor() { + case "${1:-}" in + -h | --help) doctor_usage; return 0 ;; + "") ;; + *) doctor_usage >&2; return 1 ;; + esac + + local tool missing=() + for tool in shellcheck bats tea qemu-system-x86_64 qemu-img xorriso; do + command -v "$tool" &> /dev/null || missing+=("$tool") + done + if (( ${#missing[@]} == 0 )); then + ok "shellcheck, bats, tea, QEMU and xorriso are installed" + else + problem "not installed: ${missing[*]}" "fix: bash $(sibling_dir)/update.sh" + fi + + if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + ok "KVM is available for the smoketest's virtual machines" + else + problem "can't use /dev/kvm, so the smoketest can't run VMs" \ + "fix: turn on virtualization (VT-x/AMD-V) in the firmware settings" + fi + + load_config + if [ -z "$WORKING_COPY" ]; then + problem "no working copy is set" "fix: bash $(sibling_dir)/install.sh" + elif is_coredesktop_clone "$WORKING_COPY"; then + ok "working copy: $WORKING_COPY" + else + problem "the working copy, $WORKING_COPY, isn't a clone of CoreDesktop" \ + "fix: bash $(sibling_dir)/install.sh" + fi + + doctor_deployed + + if [ -n "${COREDESKTOP_DEV_VIA_FLATPAK:-}" ] || in_flatpak; then + note "you ran this in a Flatpak, which has no pacman, systemctl or host /tmp; + coredesktop-dev runs itself on the host for you (flatpak-spawn --host)" + fi + + if [ -n "$WORKING_COPY" ] && hook_is_ours "$(hook_path)"; then + ok "the pre-push hook is installed" + else + note "the pre-push hook isn't installed (coredesktop-dev hooks install)" + fi + + return $PROBLEMS +} + +doctor_deployed() { + if ! git -C "$COREDESKTOP" rev-parse --git-dir &> /dev/null; then + problem "CoreDesktop isn't installed in $COREDESKTOP" + return + fi + + local changes + changes="$(git -C "$COREDESKTOP" status --porcelain --untracked-files=no)" + if [ -z "$changes" ]; then + ok "the deployed copy has no local changes" + else + problem "the deployed copy has local changes, so update can't pull:" \ + "${changes//$'\n'/$'\n' }" \ + "fix: coredesktop-dev deployed restore" + fi + + if ! GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$COREDESKTOP" fetch --quiet &> /dev/null; then + problem "couldn't reach the deployed copy's upstream to check it can update" + elif git -C "$COREDESKTOP" merge-base --is-ancestor HEAD '@{upstream}' 2> /dev/null; then + ok "the deployed copy can fast-forward to its upstream" + else + problem "the deployed copy has commits its upstream doesn't, so update's pull will fail" \ + "see: git -C $COREDESKTOP log @{upstream}..HEAD" + fi +} + +# hooks.sh has hook_path and hook_is_ours +source "$(dirname "${BASH_SOURCE[0]}")/hooks.sh" diff --git a/lib/hooks.sh b/lib/hooks.sh new file mode 100644 index 0000000..f0c9a42 --- /dev/null +++ b/lib/hooks.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev hooks: install or remove the working copy's pre-push +# hook, which runs coredesktop-dev check before each push + +hooks_usage() { + cat << EOF +Usage: coredesktop-dev hooks [status|install|remove] + + status Show whether the pre-push hook is installed (the default) + install Run coredesktop-dev check before each git push from the working copy + remove Remove that hook + +git push --no-verify skips the hook for one push. A pre-push hook that +coredesktop-dev didn't install is never replaced or removed. +EOF +} + +# Where git looks for the working copy's pre-push hook (core.hooksPath aware) +hook_path() { + local path + path="$(git -C "$WORKING_COPY" rev-parse --git-path hooks/pre-push)" || return 1 + [[ "$path" == /* ]] || path="$WORKING_COPY/$path" + echo "$path" +} + +hook_is_ours() { + [ -f "$1" ] && grep -qxF "$HOOK_MARKER" "$1" +} + +cmd_hooks() { + local hook + case "${1:-status}" in + -h | --help) hooks_usage; return 0 ;; + status | install | remove) ;; + *) hooks_usage >&2; return 1 ;; + esac + require_working_copy + hook="$(hook_path)" || die "Couldn't find the working copy's git hooks folder." + + case "${1:-status}" in + status) + if hook_is_ours "$hook"; then + echo "The pre-push hook is installed: $hook" + elif [ -e "$hook" ]; then + echo "$hook is someone else's hook; coredesktop-dev leaves it alone." + else + echo "The pre-push hook isn't installed. To install it: coredesktop-dev hooks install" + fi + ;; + install) + if [ -e "$hook" ] && ! hook_is_ours "$hook"; then + die "$hook already exists and coredesktop-dev didn't write it, so it's left alone." + fi + mkdir -p "$(dirname "$hook")" + install -m 755 "$(sibling_dir)/hooks/pre-push" "$hook" + echo "Installed $hook: each git push now runs coredesktop-dev check first." + ;; + remove) + if hook_is_ours "$hook"; then + rm "$hook" + echo "Removed $hook." + elif [ -e "$hook" ]; then + die "$hook isn't coredesktop-dev's, so it's left alone." + else + echo "The pre-push hook isn't installed." + fi + ;; + esac +} diff --git a/lib/smoketest.sh b/lib/smoketest.sh new file mode 100644 index 0000000..622e58c --- /dev/null +++ b/lib/smoketest.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev smoketest: run CoreDesktop's smoketest scenarios +# (smoketest/ in the working copy) on QEMU virtual machines + +source "$(dirname "${BASH_SOURCE[0]}")/vm.sh" + +CLOUD_IMAGE_URL="${SMOKETEST_CLOUD_IMAGE_URL:-https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2}" +DISK_SIZE=120G +RUNS="$SMOKETEST_STATE/runs" + +smoketest_usage() { + cat << EOF +Usage: coredesktop-dev smoketest + + list Show the scenarios + run [options] [scenario...] + Run scenarios against the working copy's HEAD + --tier pr|all pr (the default): the quick ones; all: everything, + including a fresh install and upgrades from older + commits, which take hours + --ref REF Test REF instead of HEAD + --keep After a failure, leave its VM running to look + around in, and stop + images Show the VM images + images base (Re)build the base image: Arch + GNOME + images installed [REF] (Re)build the installed image: base + install.sh at + REF (default: where HEAD branched from upstream) + shell [base|installed] Boot a throwaway copy of an image and SSH in + clean [--all] Delete old runs' logs (--all: images and the + package cache too) + +Scenarios are named by file (40-failing-migration) or number (40). Each run +keeps its logs in $RUNS. +Set SMOKETEST_VNC=1 to watch the VM's screen at VNC display 127.0.0.1:1. +EOF +} + +cmd_smoketest() { + local sub="${1:-}" + shift || true + case "$sub" in + -h | --help | "") smoketest_usage; [ -n "$sub" ]; return ;; + list | run | images | shell | clean) ;; + *) smoketest_usage >&2; return 1 ;; + esac + require_working_copy + SCENARIOS="$WORKING_COPY/smoketest/scenarios" + [ -d "$SCENARIOS" ] || die "$WORKING_COPY has no smoketest/scenarios; is it an older CoreDesktop?" + "smoketest_$sub" "$@" +} + +require_vm_tools() { + local tool + for tool in qemu-system-x86_64 qemu-img xorriso curl ssh scp ssh-keygen; do + command -v "$tool" &> /dev/null \ + || die "$tool isn't installed. Install coredesktop-dev's packages: bash $(sibling_dir)/update.sh" + done + [ -r "$KVM_DEVICE" ] && [ -w "$KVM_DEVICE" ] \ + || die "Can't use $KVM_DEVICE. Turn on virtualization (VT-x/AMD-V) in the firmware settings." +} + +# scenario_info FILE: print IMAGE, TIER, DESCRIPTION and the VARIANTS (one per +# line after the first three) +scenario_info() { + ( + source "$WORKING_COPY/smoketest/lib/scenario.sh" + source "$1" + printf '%s\n' "$IMAGE" "$TIER" "$DESCRIPTION" + (( ${#VARIANTS[@]} == 0 )) || printf '%s\n' "${VARIANTS[@]}" + ) +} + +smoketest_list() { + local file info + for file in "$SCENARIOS"/*.sh; do + mapfile -t info < <(scenario_info "$file") + printf '%-28s %-8s %-10s %s' "$(basename "$file" .sh)" "${info[1]}" "${info[0]}" "${info[2]}" + (( ${#info[@]} > 3 )) && printf ' (%d variants)' $(( ${#info[@]} - 3 )) + echo + done +} + +# Set SELECTED to the scenario files to run: those named, or those in the tier +select_scenarios() { + local tier="$1" name file found info + shift + SELECTED=() + if (( $# > 0 )); then + for name in "$@"; do + found="" + for file in "$SCENARIOS"/*.sh; do + case "$(basename "$file" .sh)" in + "$name" | "$name"-*) found="$file"; break ;; + esac + done + [ -n "$found" ] || die "No scenario called $name (see: coredesktop-dev smoketest list)" + SELECTED+=("$found") + done + return + fi + for file in "$SCENARIOS"/*.sh; do + mapfile -t info < <(scenario_info "$file") + [[ "$tier" == all || "${info[1]}" == "$tier" ]] && SELECTED+=("$file") + done + return 0 +} + +smoketest_run() { + local tier=pr ref=HEAD keep=0 names=() + while (( $# > 0 )); do + case "$1" in + --tier) tier="${2:-}"; shift 2 || die "--tier needs pr or all" ;; + --ref) ref="${2:-}"; shift 2 || die "--ref needs a commit" ;; + --keep) keep=1; shift ;; + -*) die "Unknown option: $1" ;; + *) names+=("$1"); shift ;; + esac + done + [[ "$tier" == pr || "$tier" == all ]] || die "--tier is pr or all, not $tier" + require_vm_tools + + local sha + sha="$(git -C "$WORKING_COPY" rev-parse --verify --quiet "$ref^{commit}")" || die "No such commit: $ref" + if [ "$ref" = HEAD ] && [ -n "$(git -C "$WORKING_COPY" status --porcelain --untracked-files=no)" ]; then + echo "Note: the working copy has uncommitted changes. Only HEAD is installed and" + echo "updated in the VM; the scenarios themselves are read from the working copy." + fi + + select_scenarios "$tier" "${names[@]}" + local files=("${SELECTED[@]}") + (( ${#files[@]} > 0 )) || die "No scenarios to run." + + # Build the images the chosen scenarios need, if they aren't there yet + local file info need_installed=0 + for file in "${files[@]}"; do + mapfile -t info < <(scenario_info "$file") + [ "${info[0]}" = installed ] && need_installed=1 + done + [ -f "$IMAGES/base.qcow2" ] || build_base_image + if (( need_installed )) && [ ! -f "$IMAGES/installed.qcow2" ]; then + build_installed_image "$(default_installed_ref)" + fi + + RUN="$RUNS/$(date +%Y%m%d-%H%M%S)" + mkdir -p "$RUN/shared" + echo "Testing $(git -C "$WORKING_COPY" log -1 --format='%h %s' "$sha"); logs in $RUN" + + local results=() failed=0 variants variant status + for file in "${files[@]}"; do + mapfile -t info < <(scenario_info "$file") + variants=("${info[@]:3}") + (( ${#variants[@]} > 0 )) || variants=("") + for variant in "${variants[@]}"; do + run_scenario "$file" "$variant" "$sha" "$keep" + status=$? + local label + label="$(basename "$file" .sh)${variant:+ @ $variant}" + case $status in + 0) results+=("PASS $label") ;; + 3) results+=("SKIP $label") ;; + *) results+=("FAIL $label"); failed=1 ;; + esac + if (( failed && keep )); then + break 2 + fi + done + done + + echo + printf '%s\n' "${results[@]}" | tee "$RUN/summary" + echo "Logs: $RUN" + return $failed +} + +# run_scenario FILE VARIANT SHA KEEP: boot a throwaway copy of the scenario's +# image and run it. Returns 0 for pass, 3 for skip, anything else for fail. +run_scenario() { + local file="$1" variant="$2" sha="$3" keep="$4" + local name dir + name="$(basename "$file" .sh)${variant:+@$variant}" + dir="$RUN/$name" + mkdir -p "$dir" + echo + echo "=== $name" + + ( + export SMOKETEST_RESULTS="$dir" SMOKETEST_SHARED="$RUN/shared" SMOKETEST_REF="$sha" VARIANT="$variant" + IMAGE_REF="$(cat "$IMAGES/installed.ref" 2> /dev/null)" + export IMAGE_REF + source "$WORKING_COPY/smoketest/lib/scenario.sh" + source "$file" + + qemu-img create -q -f qcow2 -b "$IMAGES/$IMAGE.qcow2" -F qcow2 "$dir/disk.qcow2" \ + || die "Couldn't make the VM's disk." + vm_start "$dir" "$dir/disk.qcow2" + trap 'finish_scenario $? '"$keep" EXIT + vm_wait_up + smoketest_prepare + set -e + scenario + ) 2>&1 | tee "$dir/scenario.log" + return "${PIPESTATUS[0]}" +} + +# Runs as the scenario's subshell exits: stop the VM and delete its disk, +# unless it failed and --keep was given +finish_scenario() { + local status="$1" keep="$2" + if (( status != 0 && status != SKIP_STATUS && keep )); then + echo + echo "The VM is still running. To look around: $(vm_ssh_command)" + echo "To stop it: kill $(vm_pid) Its disk: $VM_DIR/disk.qcow2" + return + fi + vm_kill + rm -f "$VM_DIR/disk.qcow2" +} + +# Where the installed image is built by default: the commit HEAD branched +# from upstream, so updating it to HEAD exercises the branch's changes +default_installed_ref() { + git -C "$WORKING_COPY" merge-base HEAD '@{upstream}' 2> /dev/null \ + || git -C "$WORKING_COPY" merge-base HEAD origin/main 2> /dev/null \ + || git -C "$WORKING_COPY" rev-parse HEAD +} + +download_cloud_image() { + local image="$IMAGES/cloudimg.qcow2" expected actual + [ -f "$image" ] && return 0 + mkdir -p "$IMAGES" + echo "Downloading Arch's cloud image..." + curl -fL --progress-bar -o "$image.part" "$CLOUD_IMAGE_URL" || die "Couldn't download $CLOUD_IMAGE_URL" + expected="$(curl -fsSL "$CLOUD_IMAGE_URL.SHA256" | awk '{print $1}')" \ + || die "Couldn't download $CLOUD_IMAGE_URL.SHA256" + actual="$(sha256sum "$image.part" | awk '{print $1}')" + [ -n "$expected" ] && [ "$expected" = "$actual" ] \ + || die "The cloud image's checksum doesn't match; try again." + mv "$image.part" "$image" +} + +build_base_image() { + require_vm_tools + download_cloud_image + local build="$SMOKETEST_STATE/build-base" + rm -rf "$build" + mkdir -p "$build/seed" + echo "Building the base image (Arch + GNOME); log: $build/build.log" + + cp --reflink=auto "$IMAGES/cloudimg.qcow2" "$build/disk.qcow2" + qemu-img resize -q "$build/disk.qcow2" "$DISK_SIZE" + + ensure_ssh_key + sed "s|@SSH_KEY@|$(cat "$SSH_KEY.pub")|" "$(sibling_dir)/vm/user-data" > "$build/seed/user-data" + printf 'instance-id: coredesktop-smoketest\nlocal-hostname: coredesktop-smoketest\n' \ + > "$build/seed/meta-data" + xorriso -as mkisofs -quiet -output "$build/seed.iso" -volid cidata -joliet -rock \ + "$build/seed/user-data" "$build/seed/meta-data" || die "Couldn't make the cloud-init disk." + + ( + vm_start "$build" "$build/disk.qcow2" -cdrom "$build/seed.iso" + trap vm_kill EXIT + wait_for_ssh + vm_ssh 'cloud-init status --wait' < /dev/null > /dev/null || true + vm_put "$(sibling_dir)/vm/provision.sh" /tmp/provision.sh || die "Couldn't copy provision.sh to the VM." + vm_exec 'sudo bash /tmp/provision.sh' || die "Provisioning failed; see $build/build.log" + vm_stop + ) 2>&1 | tee "$build/build.log" + (( PIPESTATUS[0] == 0 )) || die "Building the base image failed; see $build/build.log" + + mv "$build/disk.qcow2" "$IMAGES/base.qcow2" + # Built on the old base image + rm -f "$IMAGES/installed.qcow2" "$IMAGES/installed.ref" + rm -rf "$build" + echo "Built $IMAGES/base.qcow2" +} + +# build_installed_image REF: the base image after install.sh at REF +build_installed_image() { + local ref sha build="$SMOKETEST_STATE/build-installed" + ref="$1" + sha="$(git -C "$WORKING_COPY" rev-parse --verify --quiet "$ref^{commit}")" || die "No such commit: $ref" + require_vm_tools + [ -f "$IMAGES/base.qcow2" ] || build_base_image + rm -rf "$build" + mkdir -p "$build" + echo "Building the installed image at $(git -C "$WORKING_COPY" log -1 --format='%h %s' "$sha");" \ + "this takes an hour or more. Log: $build/build.log" + + qemu-img create -q -f qcow2 -b "$IMAGES/base.qcow2" -F qcow2 "$build/disk.qcow2" + ( + export SMOKETEST_RESULTS="$build" SMOKETEST_SHARED="$build" SMOKETEST_REF="$sha" + IMAGE=base + source "$WORKING_COPY/smoketest/lib/scenario.sh" + vm_start "$build" "$build/disk.qcow2" + trap vm_kill EXIT + vm_wait_up + smoketest_install_image "$sha" + # The README says to reboot after installing + vm_reboot + vm_stop + ) 2>&1 | tee "$build/build.log" + (( PIPESTATUS[0] == 0 )) || die "Building the installed image failed; logs in $build" + + mv "$build/disk.qcow2" "$IMAGES/installed.qcow2" + echo "$sha" > "$IMAGES/installed.ref" + echo "Built $IMAGES/installed.qcow2" +} + +smoketest_images() { + case "${1:-}" in + "") + local image + for image in base installed; do + if [ -f "$IMAGES/$image.qcow2" ]; then + printf '%-10s built %s' "$image" "$(date -r "$IMAGES/$image.qcow2" '+%Y-%m-%d %H:%M')" + [ "$image" = installed ] \ + && printf ', at %s' "$(git -C "$WORKING_COPY" log -1 --format='%h %s' "$(cat "$IMAGES/installed.ref")" 2> /dev/null)" + echo + else + printf '%-10s not built\n' "$image" + fi + done + ;; + base) build_base_image ;; + installed) build_installed_image "${2:-$(default_installed_ref)}" ;; + *) smoketest_usage >&2; return 1 ;; + esac +} + +smoketest_shell() { + local image="${1:-installed}" dir + [[ "$image" == base || "$image" == installed ]] || die "The image is base or installed, not $image." + require_vm_tools + [ -f "$IMAGES/$image.qcow2" ] || die "The $image image isn't built: coredesktop-dev smoketest images $image" + dir="$SMOKETEST_STATE/shell" + rm -rf "$dir" + mkdir -p "$dir" + qemu-img create -q -f qcow2 -b "$IMAGES/$image.qcow2" -F qcow2 "$dir/disk.qcow2" + vm_start "$dir" "$dir/disk.qcow2" + trap 'vm_kill; rm -rf "$dir"' EXIT + echo "Starting a throwaway copy of the $image image..." + vm_wait_up + vm_ssh -t +} + +smoketest_clean() { + case "${1:-}" in + "") rm -rf "$RUNS" && echo "Deleted $RUNS" ;; + --all) rm -rf "$SMOKETEST_STATE" && echo "Deleted $SMOKETEST_STATE" ;; + *) smoketest_usage >&2; return 1 ;; + esac +} diff --git a/lib/try.sh b/lib/try.sh new file mode 100644 index 0000000..4afaeb2 --- /dev/null +++ b/lib/try.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Purpose : coredesktop-dev try: run the working copy's code as if it were the +# deployed CoreDesktop, in a throwaway home with its own session + +try_usage() { + cat << EOF +Usage: coredesktop-dev try [--keep] [command...] + +Runs command (or a shell) with a throwaway HOME where +~/.local/share/coredesktop is the working copy, and update, configure, version +etc. in ~/.local/bin are the working copy's. It gets its own XDG_RUNTIME_DIR +and D-Bus session, and no display, so it can't touch your desktop session. + +sudo, yay, pacman and systemctl still change this machine. + + --keep Keep the throwaway folder afterwards, and print where it is + +Example: coredesktop-dev try configure --list +EOF +} + +TRY_COMMANDS=(update refresh sibling version configure audit) + +cmd_try() { + local keep=0 + while [ $# -gt 0 ]; do + case "$1" in + -h | --help) try_usage; return 0 ;; + --keep) keep=1; shift ;; + --) shift; break ;; + *) break ;; + esac + done + require_working_copy + + local tmp home runtime cmd status + tmp="$(mktemp -d "${TMPDIR:-/tmp}/coredesktop-dev-try.XXXXXX")" || return 1 + home="$tmp/home" + runtime="$tmp/runtime" + mkdir -p "$home/.local/share" "$home/.local/bin" "$home/.local/state" \ + "$home/.config" "$home/.cache" + mkdir -m 700 "$runtime" + + ln -s "$WORKING_COPY" "$home/.local/share/coredesktop" + for cmd in "${TRY_COMMANDS[@]}"; do + ln -s "$WORKING_COPY/$cmd" "$home/.local/bin/$cmd" + done + + echo "coredesktop-dev try: HOME is $home, with the working copy as CoreDesktop." >&2 + echo "Careful: sudo, yay, pacman and systemctl still change this machine." >&2 + + (( $# > 0 )) || set -- bash + env -u DISPLAY -u WAYLAND_DISPLAY -u DBUS_SESSION_BUS_ADDRESS -u SSH_AUTH_SOCK \ + HOME="$home" \ + XDG_CONFIG_HOME="$home/.config" \ + XDG_DATA_HOME="$home/.local/share" \ + XDG_STATE_HOME="$home/.local/state" \ + XDG_CACHE_HOME="$home/.cache" \ + XDG_RUNTIME_DIR="$runtime" \ + PATH="$home/.local/bin:$PATH" \ + dbus-run-session -- "$@" + status=$? + + if (( keep )); then + echo "Kept $tmp" >&2 + else + rm -rf "$tmp" + fi + return $status +} diff --git a/lib/vm.sh b/lib/vm.sh new file mode 100644 index 0000000..38b113d --- /dev/null +++ b/lib/vm.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# +# Purpose : Start, reach and stop the smoketest's QEMU virtual machines. Defines +# what CoreDesktop's smoketest/lib/scenario.sh expects of a driver: +# vm_exec, vm_put, vm_reboot, VM_GIT_SSH and VM_GIT_URL. + +SMOKETEST_STATE="${XDG_CACHE_HOME:-$HOME/.cache}/coredesktop-dev/smoketest" +IMAGES="$SMOKETEST_STATE/images" +# Shared with every VM as its /var/cache/pacman/pkg, so packages download once +PKG_CACHE="$SMOKETEST_STATE/pkg" +SSH_KEY="$SMOKETEST_STATE/id_ed25519" +KVM_DEVICE="${KVM_DEVICE:-/dev/kvm}" + +VM_USER=tester +VM_MEMORY="${SMOKETEST_MEMORY:-8G}" +VM_CPUS="${SMOKETEST_CPUS:-4}" +# Longest a single command in the VM may run (an install takes an hour or more) +VM_EXEC_TIMEOUT="${SMOKETEST_EXEC_TIMEOUT:-4h}" +# Seconds to wait for the VM to answer on SSH, and for GNOME to start +VM_SSH_TIMEOUT="${SMOKETEST_SSH_TIMEOUT:-900}" +VM_DESKTOP_TIMEOUT="${SMOKETEST_DESKTOP_TIMEOUT:-300}" + +SSH_OPTS=( + -i "$SSH_KEY" + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -o BatchMode=yes + -o ConnectTimeout=10 + -o ServerAliveInterval=15 + -o ServerAliveCountMax=8 +) + +# Commands in the VM run as they would in a GNOME Terminal: with the GNOME +# session's environment, in an interactive bash that has read ~/.bashrc +VM_SESSION_ENV='eval "$(systemctl --user show-environment 2> /dev/null | sed "s/^/export /")" +export TERM="${TERM:-dumb}"' + +ensure_ssh_key() { + mkdir -p "$SMOKETEST_STATE" + [ -f "$SSH_KEY" ] || ssh-keygen -q -t ed25519 -N '' -C coredesktop-smoketest -f "$SSH_KEY" +} + +free_port() { + local port i + for i in $(seq 50); do + port=$((20000 + RANDOM % 20000)) + [ -z "$(ss -Hltn "( sport = :$port )" 2> /dev/null)" ] && { echo "$port"; return 0; } + done + return 1 +} + +# vm_start DIR DISK [QEMU ARGS...]: boot DISK in the background. DIR gets the +# pid file and the serial console's log. +vm_start() { + VM_DIR="$1" + local disk="$2" + shift 2 + local display=(-display none) + [ -n "${SMOKETEST_VNC:-}" ] && display+=(-vnc "127.0.0.1:$SMOKETEST_VNC") + + ensure_ssh_key + mkdir -p "$VM_DIR" "$PKG_CACHE" + VM_PORT="$(free_port)" || die "Couldn't find a free port for the VM's SSH." + + qemu-system-x86_64 \ + -name coredesktop-smoketest \ + -machine q35,accel=kvm \ + -cpu host \ + -smp "$VM_CPUS" \ + -m "$VM_MEMORY" \ + -drive "file=$disk,if=virtio,format=qcow2,discard=unmap" \ + -nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:$VM_PORT-:22" \ + -virtfs "local,path=$PKG_CACHE,mount_tag=pkgcache,security_model=mapped-xattr" \ + -vga virtio \ + "${display[@]}" \ + -serial "file:$VM_DIR/serial.log" \ + -pidfile "$VM_DIR/qemu.pid" \ + -daemonize \ + "$@" || die "QEMU didn't start." + + VM_GIT_SSH="ssh$(printf ' %q' "${SSH_OPTS[@]}")" + VM_GIT_URL="ssh://$VM_USER@127.0.0.1:$VM_PORT" + export VM_GIT_SSH VM_GIT_URL +} + +vm_pid() { + cat "$VM_DIR/qemu.pid" 2> /dev/null +} + +vm_running() { + local pid + pid="$(vm_pid)" && [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null +} + +vm_ssh() { + ssh "${SSH_OPTS[@]}" -p "$VM_PORT" "$VM_USER@127.0.0.1" "$@" +} + +# The command to SSH in by hand +vm_ssh_command() { + echo "ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $VM_PORT $VM_USER@127.0.0.1" +} + +wait_for_ssh() { + local deadline=$((SECONDS + VM_SSH_TIMEOUT)) + until vm_ssh true < /dev/null 2> /dev/null; do + vm_running || die "The VM stopped. Its console log: $VM_DIR/serial.log" + (( SECONDS < deadline )) || die "The VM didn't answer on SSH. Its console log: $VM_DIR/serial.log" + sleep 5 + done +} + +# Wait for the automatic GNOME login. Not fatal: the health checks catch a +# desktop that didn't start. +wait_for_desktop() { + local deadline=$((SECONDS + VM_DESKTOP_TIMEOUT)) + until vm_ssh "pgrep -u $VM_USER -x gnome-shell" < /dev/null &> /dev/null; do + if (( SECONDS >= deadline )); then + echo "Warning: GNOME didn't start in the VM within ${VM_DESKTOP_TIMEOUT}s." + return 0 + fi + sleep 5 + done +} + +vm_wait_up() { + wait_for_ssh + wait_for_desktop +} + +# vm_exec SCRIPT: run SCRIPT with bash in the VM, as tester, in the GNOME +# session's environment. Interactive bash's complaints about having no +# terminal are dropped. +vm_exec() { + timeout "$VM_EXEC_TIMEOUT" ssh "${SSH_OPTS[@]}" -p "$VM_PORT" -T "$VM_USER@127.0.0.1" \ + "bash -ic $(printf '%q' "$VM_SESSION_ENV +$1")" < /dev/null \ + 2> >(grep -v -e '^bash: cannot set terminal process group' -e '^bash: no job control in this shell' >&2) +} + +# vm_put LOCAL REMOTE +vm_put() { + scp -q "${SSH_OPTS[@]}" -P "$VM_PORT" "$1" "$VM_USER@127.0.0.1:$2" +} + +vm_boot_id() { + vm_ssh 'cat /proc/sys/kernel/random/boot_id' < /dev/null 2> /dev/null +} + +vm_reboot() { + local before deadline=$((SECONDS + VM_SSH_TIMEOUT)) + echo "== reboot" + before="$(vm_boot_id)" + vm_ssh 'sudo systemctl reboot' < /dev/null &> /dev/null || true + until [ "$(vm_boot_id)" != "$before" ] && [ -n "$(vm_boot_id)" ]; do + vm_running || die "The VM stopped while rebooting. Its console log: $VM_DIR/serial.log" + (( SECONDS < deadline )) || die "The VM didn't come back after rebooting. Its console log: $VM_DIR/serial.log" + sleep 5 + done + wait_for_desktop +} + +# Shut the VM down cleanly (so its disk can be kept), killing it if it hangs +vm_stop() { + vm_running || return 0 + vm_ssh 'sudo systemctl poweroff' < /dev/null &> /dev/null || true + local deadline=$((SECONDS + 120)) + while vm_running && (( SECONDS < deadline )); do + sleep 2 + done + vm_kill +} + +vm_kill() { + local pid + pid="$(vm_pid)" || return 0 + [ -n "$pid" ] && kill "$pid" 2> /dev/null + rm -f "$VM_DIR/qemu.pid" + return 0 +} diff --git a/tests/check.bats b/tests/check.bats new file mode 100644 index 0000000..1dd6981 --- /dev/null +++ b/tests/check.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + common_setup + make_working_copy + stub make + # Fails on any file that contains BAD, as if it had a warning + stub shellcheck 'for f in "$@"; do [ -f "$f" ] && grep -q BAD "$f" && exit 1; done; exit 0' + stub bats 'echo 7' +} + +@test "check runs make lint and make test, and shellcheck on untracked scripts" { + printf '%s\n' '#!/bin/bash' 'echo fine' > "$WC/new-script" + printf '%s\n' 'echo fine' > "$WC/new-hook.sh" + echo "not a script" > "$WC/notes.txt" + + run cdd check + [ "$status" -eq 0 ] + called make --no-print-directory lint + called make --no-print-directory test + grep -q "^shellcheck --severity=warning .*new-script" "$STUB_LOG" + grep -q "^shellcheck --severity=warning .*new-hook.sh" "$STUB_LOG" + [[ "$output" == *"shellcheck: ok ("*" files, 2 untracked) bats: ok (7 tests)"* ]] + run grep -q "^shellcheck .*notes.txt" "$STUB_LOG" + [ "$status" -eq 1 ] +} + +@test "check fails on a warning in an untracked script" { + printf '%s\n' '#!/bin/bash' 'BAD' > "$WC/new-script" + + run cdd check + [ "$status" -eq 1 ] + [[ "$output" == *"shellcheck: FAILED"* ]] +} + +@test "check skips ignored files" { + echo "ignored-script" > "$WC/.gitignore" + printf '%s\n' '#!/bin/bash' 'BAD' > "$WC/ignored-script" + + run cdd check + [ "$status" -eq 0 ] +} + +@test "check --fast runs only the shellcheck steps" { + run cdd check --fast + [ "$status" -eq 0 ] + called make --no-print-directory lint + uncalled make --no-print-directory test + [[ "$output" == *"bats: skipped (--fast)"* ]] +} + +@test "check fails when make test fails" { + stub make '[ "$2" != test ]' + run cdd check + [ "$status" -eq 1 ] + [[ "$output" == *"bats: FAILED"* ]] +} diff --git a/tests/deployed.bats b/tests/deployed.bats new file mode 100644 index 0000000..211b176 --- /dev/null +++ b/tests/deployed.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats + +load test_helper + +@test "deployed status shows the commit, how far behind, and local changes" { + make_working_copy + git -C "$WC" commit -q --allow-empty -m "new" + git -C "$WC" push -q origin main + echo "# edited" >> "$DEPLOYED/update" + + run cdd deployed + [ "$status" -eq 0 ] + [[ "$output" == *"Branch: main"* ]] + [[ "$output" == *"CoreDesktop"* ]] + [[ "$output" == *"Behind: 1 commits"* ]] + [[ "$output" == *"M update"* ]] +} + +@test "deployed restore asks first, and changes nothing on no" { + echo "# edited" >> "$DEPLOYED/update" + + run cdd deployed restore <<< n + [ "$status" -eq 1 ] + [[ "$output" == *"Nothing changed."* ]] + grep -q "# edited" "$DEPLOYED/update" +} + +@test "deployed restore puts back tracked files and leaves untracked ones" { + echo "# edited" >> "$DEPLOYED/update" + rm "$DEPLOYED/utils.sh" + echo "mine" > "$DEPLOYED/untracked-file" + + run cdd deployed restore <<< y + [ "$status" -eq 0 ] + [[ "$output" == *"untracked-file"* ]] + [ -f "$DEPLOYED/utils.sh" ] + [ "$(cat "$DEPLOYED/untracked-file")" = mine ] + [ -z "$(git -C "$DEPLOYED" status --porcelain --untracked-files=no)" ] + run grep -q "# edited" "$DEPLOYED/update" + [ "$status" -eq 1 ] +} + +@test "deployed restore puts back staged changes too" { + echo "# edited" >> "$DEPLOYED/update" + git -C "$DEPLOYED" add update + + run cdd deployed restore <<< y + [ "$status" -eq 0 ] + [ -z "$(git -C "$DEPLOYED" status --porcelain --untracked-files=no)" ] +} + +@test "deployed restore with nothing to restore doesn't ask" { + run cdd deployed restore < /dev/null + [ "$status" -eq 0 ] + [[ "$output" == *"no local changes"* ]] +} diff --git a/tests/doctor.bats b/tests/doctor.bats new file mode 100644 index 0000000..fae5db6 --- /dev/null +++ b/tests/doctor.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats + +load test_helper + +@test "doctor reports local changes in the deployed copy and how to fix them" { + make_working_copy + echo "# edited" >> "$DEPLOYED/update" + + run cdd doctor + [ "$status" -eq 1 ] + [[ "$output" == *"PROBLEM the deployed copy has local changes"* ]] + [[ "$output" == *"M update"* ]] + [[ "$output" == *"coredesktop-dev deployed restore"* ]] +} + +@test "doctor is happy with a clean deployed copy that can fast-forward" { + make_working_copy + # Upstream moves on; the deployed copy is behind, which is fine + git -C "$WC" commit -q --allow-empty -m "new" + git -C "$WC" push -q origin main + + run cdd doctor + [[ "$output" == *"ok the deployed copy has no local changes"* ]] + [[ "$output" == *"ok the deployed copy can fast-forward to its upstream"* ]] + [[ "$output" == *"ok working copy: $WC"* ]] +} + +@test "doctor reports a deployed copy with commits upstream doesn't have" { + make_working_copy + git -C "$DEPLOYED" commit -q --allow-empty -m "local commit" + + run cdd doctor + [ "$status" -eq 1 ] + [[ "$output" == *"has commits its upstream doesn't"* ]] +} + +@test "doctor reports a missing working copy" { + run cdd doctor + [ "$status" -eq 1 ] + [[ "$output" == *"PROBLEM no working copy is set"* ]] +} + +@test "doctor reports when it was run from a Flatpak" { + make_working_copy + COREDESKTOP_DEV_VIA_FLATPAK=1 run cdd doctor + [[ "$output" == *"note you ran this in a Flatpak"* ]] +} + +@test "doctor reports the pre-push hook" { + make_working_copy + run cdd doctor + [[ "$output" == *"the pre-push hook isn't installed"* ]] + + cdd hooks install + run cdd doctor + [[ "$output" == *"ok the pre-push hook is installed"* ]] +} + +@test "in a Flatpak, coredesktop-dev re-runs itself on the host" { + stub flatpak-spawn + FLATPAK_ID=com.vscodium.codium run cdd doctor + [ "$status" -eq 0 ] + called flatpak-spawn --host --env=COREDESKTOP_DEV_VIA_FLATPAK=1 \ + "$(readlink -f "$PROJECT/bin/coredesktop-dev")" doctor +} + +@test "--help works in a Flatpak without going to the host" { + stub flatpak-spawn + FLATPAK_ID=com.vscodium.codium run cdd doctor --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: coredesktop-dev doctor"* ]] + not_called flatpak-spawn +} diff --git a/tests/hooks.bats b/tests/hooks.bats new file mode 100644 index 0000000..e3cbaf7 --- /dev/null +++ b/tests/hooks.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + common_setup + make_working_copy + HOOK="$WC/.git/hooks/pre-push" +} + +@test "hooks install installs the pre-push hook" { + run cdd hooks install + [ "$status" -eq 0 ] + [ -x "$HOOK" ] + cmp "$PROJECT/hooks/pre-push" "$HOOK" + + run cdd hooks + [[ "$output" == *"The pre-push hook is installed"* ]] +} + +@test "hooks install won't overwrite someone else's hook" { + printf '%s\n' '#!/bin/bash' 'mine' > "$HOOK" + + run cdd hooks install + [ "$status" -eq 1 ] + [[ "$output" == *"didn't write it"* ]] + [ "$(tail -n 1 "$HOOK")" = mine ] +} + +@test "hooks remove removes only our hook" { + cdd hooks install + run cdd hooks remove + [ "$status" -eq 0 ] + [ ! -e "$HOOK" ] + + printf '%s\n' '#!/bin/bash' 'mine' > "$HOOK" + run cdd hooks remove + [ "$status" -eq 1 ] + [ -e "$HOOK" ] +} + +@test "hooks follows core.hooksPath" { + git -C "$WC" config core.hooksPath .githooks + run cdd hooks install + [ "$status" -eq 0 ] + [ -x "$WC/.githooks/pre-push" ] +} + +@test "the hook stops a push when check fails, and says how to skip it" { + mkdir -p "$HOME/.local/bin" + printf '%s\n' '#!/bin/bash' 'exit 1' > "$HOME/.local/bin/coredesktop-dev" + chmod +x "$HOME/.local/bin/coredesktop-dev" + + run bash "$PROJECT/hooks/pre-push" + [ "$status" -eq 1 ] + [[ "$output" == *"git push --no-verify"* ]] +} + +@test "the hook lets a push through when check passes" { + mkdir -p "$HOME/.local/bin" + printf '%s\n' '#!/bin/bash' 'exit 0' > "$HOME/.local/bin/coredesktop-dev" + chmod +x "$HOME/.local/bin/coredesktop-dev" + + run bash "$PROJECT/hooks/pre-push" + [ "$status" -eq 0 ] +} diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..16e6e56 --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +load test_helper + +config() { + cat "$HOME/.config/coredesktop-dev/config" +} + +@test "install refuses the deployed copy, then takes a working copy" { + git clone -q "$UPSTREAM" "$WC" + + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "$DEPLOYED" "$WC" n)" + [ "$status" -eq 0 ] + [[ "$output" == *"is the deployed copy"* ]] + [ "$(config)" = "WORKING_COPY=$(printf '%q' "$WC")" ] + [ "$(readlink "$HOME/.local/bin/coredesktop-dev")" = "$PROJECT/bin/coredesktop-dev" ] + called install_packages shellcheck bats tea qemu-base libisoburn +} + +@test "install refuses the deployed copy however it's spelled" { + git clone -q "$UPSTREAM" "$WC" + + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' '~/.local/share/coredesktop/' "$WC" n)" + [ "$status" -eq 0 ] + [[ "$output" == *"is the deployed copy"* ]] + [ "$(config)" = "WORKING_COPY=$(printf '%q' "$WC")" ] +} + +@test "install offers to clone the working copy from the deployed copy's origin" { + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "" y n)" + [ "$status" -eq 0 ] + [ "$(git -C "$WC" remote get-url origin)" = "$UPSTREAM" ] + [ "$(config)" = "WORKING_COPY=$(printf '%q' "$WC")" ] +} + +@test "install won't use a folder that isn't a clone and isn't empty" { + mkdir -p "$HOME/other" + touch "$HOME/other/file" + git clone -q "$UPSTREAM" "$WC" + + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "$HOME/other" "$WC" n)" + [ "$status" -eq 0 ] + [[ "$output" == *"isn't a clone of CoreDesktop, and isn't empty"* ]] + [ "$(config)" = "WORKING_COPY=$(printf '%q' "$WC")" ] +} + +@test "install is safe to re-run, and Enter keeps the saved working copy" { + # Somewhere other than the default, ~/Projects/coredesktop + other="$HOME/work/coredesktop" + git clone -q "$UPSTREAM" "$other" + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "$other" n)" + [ "$status" -eq 0 ] + + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "" n)" + [ "$status" -eq 0 ] + [ "$(config)" = "WORKING_COPY=$(printf '%q' "$other")" ] + [ ! -e "$WC" ] +} + +@test "install stops without an answer instead of looping" { + run bash "$PROJECT/install.sh" < /dev/null + [ "$status" -eq 1 ] + [[ "$output" == *"No answer"* ]] + [ ! -f "$HOME/.config/coredesktop-dev/config" ] +} + +@test "install offers the pre-push hook, and installs it on yes" { + git clone -q "$UPSTREAM" "$WC" + + run bash "$PROJECT/install.sh" <<< "$(printf '%s\n' "$WC" y)" + [ "$status" -eq 0 ] + grep -qxF "# Installed by coredesktop-dev; remove with: coredesktop-dev hooks remove" \ + "$WC/.git/hooks/pre-push" +} + +@test "install stops if CoreDesktop isn't installed" { + rm -rf "$DEPLOYED" + run bash "$PROJECT/install.sh" <<< "" + [ "$status" -eq 1 ] + [[ "$output" == *"CoreDesktop isn't installed"* ]] +} diff --git a/tests/smoketest.bats b/tests/smoketest.bats new file mode 100644 index 0000000..911096d --- /dev/null +++ b/tests/smoketest.bats @@ -0,0 +1,169 @@ +#!/usr/bin/env bats + +load test_helper + +# The driver with fake QEMU: "VMs" are sleep processes, and ssh runs its command +# right here. The working copy has a small fake smoketest of its own. + +setup() { + common_setup + make_working_copy + + export KVM_DEVICE="$BATS_TEST_TMPDIR/kvm" + touch "$KVM_DEVICE" + export SMOKETEST_DESKTOP_TIMEOUT=0 SMOKETEST_SSH_TIMEOUT=5 + STATE="$HOME/.cache/coredesktop-dev/smoketest" + mkdir -p "$STATE/images" + touch "$STATE/images/base.qcow2" "$STATE/images/installed.qcow2" + git -C "$WC" rev-parse HEAD > "$STATE/images/installed.ref" + + # A VM is a sleep that lets go of bats' output pipes + stub qemu-system-x86_64 'while [ $# -gt 0 ]; do [ "$1" = -pidfile ] && pidfile="$2"; shift; done + /usr/bin/sleep 300 > /dev/null 2>&1 3>&- & + echo $! > "$pidfile"' + stub qemu-img 'touch "${@: -1}"' + stub xorriso + stub curl + stub ss + stub ssh-keygen 'touch "${@: -1}" "${@: -1}.pub"' + stub systemctl + stub ssh 'while [ $# -gt 0 ]; do + case "$1" in -i | -o | -p) shift 2 ;; *@*) shift; break ;; *) shift ;; esac + done + [ $# -gt 0 ] || exit 0 + cd "$HOME" && exec bash -c "$*"' + stub scp 'dest="${*: -1}"; cp "${@: -2:1}" "${dest#*:}"' + + SC="$WC/smoketest/scenarios" + mkdir -p "$SC" "$WC/smoketest/lib" + cat > "$WC/smoketest/lib/scenario.sh" << 'EOF' +SKIP_STATUS=3 +smoketest_prepare() { echo "prepare $IMAGE $SMOKETEST_REF $IMAGE_REF"; } +fail() { echo "FAIL: $*"; exit 1; } +skip() { echo "SKIP: $*"; exit 3; } +EOF + scenario_file 10-passes pr installed 'vm_exec "echo in the vm"; echo "ref=$SMOKETEST_REF"' + scenario_file 20-fails pr base 'false; echo "still going"' + scenario_file 30-variants nightly base 'echo "variant $VARIANT"' 'VARIANTS=(one two)' + scenario_file 40-skips pr base 'skip "not today"' +} + +# scenario_file NAME TIER IMAGE BODY [EXTRA] +scenario_file() { + printf '%s\n' "DESCRIPTION=\"$1 does things\"" "TIER=$2" "IMAGE=$3" "${5:-}" \ + "scenario() {" "$4" "}" > "$SC/$1.sh" +} + +last_run() { + ls -d "$STATE/runs/"* | tail -n 1 +} + +@test "list shows each scenario's tier, image and description" { + run cdd smoketest list + [ "$status" -eq 0 ] + [[ "$output" == *"10-passes"*"pr"*"installed"*"10-passes does things"* ]] + [[ "$output" == *"30-variants"*"nightly"*"base"*"(2 variants)"* ]] +} + +@test "run runs the pr scenarios against HEAD and reports each" { + run cdd smoketest run + [ "$status" -eq 1 ] + [[ "$output" == *"PASS 10-passes"* ]] + [[ "$output" == *"FAIL 20-fails"* ]] + [[ "$output" == *"SKIP 40-skips"* ]] + [[ "$output" != *"30-variants"* ]] + [[ "$output" == *"in the vm"* ]] + [[ "$output" == *"ref=$(git -C "$WC" rev-parse HEAD)"* ]] + [ -f "$(last_run)/summary" ] +} + +@test "a scenario stops at its first failing command" { + run cdd smoketest run 20 + [ "$status" -eq 1 ] + [[ "$output" != *"still going"* ]] +} + +@test "run picks scenarios by name or number" { + run cdd smoketest run 10 40-skips + [ "$status" -eq 0 ] + [[ "$output" == *"PASS 10-passes"* ]] + [[ "$output" == *"SKIP 40-skips"* ]] + [[ "$output" != *"20-fails"* ]] +} + +@test "run stops on an unknown scenario before starting any VM" { + run cdd smoketest run 10 nosuch + [ "$status" -eq 1 ] + [[ "$output" == *"No scenario called nosuch"* ]] + not_called qemu-system-x86_64 +} + +@test "each variant runs in its own VM" { + run cdd smoketest run 30 + [ "$status" -eq 0 ] + [[ "$output" == *"PASS 30-variants @ one"* ]] + [[ "$output" == *"PASS 30-variants @ two"* ]] + [ "$(grep -c '^qemu-system-x86_64 ' "$STUB_LOG")" -eq 2 ] + grep -q "variant two" "$(last_run)/30-variants@two/scenario.log" +} + +@test "--tier all runs every scenario" { + run cdd smoketest run --tier all + [[ "$output" == *"30-variants @ one"* ]] + [[ "$output" == *"10-passes"* ]] +} + +@test "the scenario boots a copy of its image, and gets the installed image's commit" { + run cdd smoketest run 10 + [ "$status" -eq 0 ] + grep -q "^qemu-img create -q -f qcow2 -b $STATE/images/installed.qcow2 " "$STUB_LOG" + [[ "$output" == *"prepare installed $(git -C "$WC" rev-parse HEAD) $(cat "$STATE/images/installed.ref")"* ]] +} + +@test "the VM is stopped and its disk deleted after each scenario" { + run cdd smoketest run 10 20 + for dir in "$(last_run)"/*/; do + [ ! -e "$dir/qemu.pid" ] + [ ! -e "$dir/disk.qcow2" ] + done +} + +@test "--keep leaves a failed scenario's VM running, and stops the run" { + run cdd smoketest run --keep 20 10 + [ "$status" -eq 1 ] + [[ "$output" == *"The VM is still running"* ]] + [[ "$output" != *"PASS 10-passes"* ]] + pid="$(cat "$(last_run)/20-fails/qemu.pid")" + kill -0 "$pid" + kill "$pid" +} + +@test "run --ref tests another commit" { + first="$(git -C "$WC" rev-parse HEAD)" + git -C "$WC" commit -q --allow-empty -m second + run cdd smoketest run --ref "$first" 10 + [ "$status" -eq 0 ] + [[ "$output" == *"ref=$first"* ]] +} + +@test "run stops without KVM" { + rm "$KVM_DEVICE" + run cdd smoketest run 10 + [ "$status" -eq 1 ] + [[ "$output" == *"Can't use $KVM_DEVICE"* ]] +} + +@test "smoketest needs a working copy with a smoketest folder" { + rm -rf "$WC/smoketest" + run cdd smoketest list + [ "$status" -eq 1 ] + [[ "$output" == *"has no smoketest/scenarios"* ]] +} + +@test "images shows what's built" { + rm "$STATE/images/installed.qcow2" + run cdd smoketest images + [ "$status" -eq 0 ] + [[ "$output" == *"base built"* ]] + [[ "$output" == *"installed not built"* ]] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..c4642da --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,92 @@ +#!/bin/bash + +# Shared setup for the bats tests, after CoreDesktop's: each test gets its own +# HOME and a bin dir at the front of PATH for fake commands. On top of that, +# HOME holds a fake CoreDesktop: an upstream repo, the deployed copy cloned +# from it, and a working copy at ~/Projects/coredesktop. + +# Used by the .bats files that load this one +# shellcheck disable=SC2034 +PROJECT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + +common_setup() { + export HOME="$BATS_TEST_TMPDIR/home" + export STUB_BIN="$BATS_TEST_TMPDIR/bin" + export STUB_LOG="$BATS_TEST_TMPDIR/stub.log" + export TMPDIR="$BATS_TEST_TMPDIR/tmp" + mkdir -p "$HOME" "$STUB_BIN" "$TMPDIR" + : > "$STUB_LOG" + export PATH="$STUB_BIN:$PATH" + unset XDG_CONFIG_HOME XDG_CACHE_HOME FLATPAK_ID COREDESKTOP_DEV_VIA_FLATPAK + export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@localhost + export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@localhost + export GIT_CONFIG_NOSYSTEM=1 + + UPSTREAM="$BATS_TEST_TMPDIR/upstream.git" + DEPLOYED="$HOME/.local/share/coredesktop" + WC="$HOME/Projects/coredesktop" + make_coredesktop +} + +setup() { + common_setup +} + +# A CoreDesktop upstream with one commit, and the deployed copy cloned from it. +# The fake utils.sh logs install_packages instead of installing anything. +make_coredesktop() { + local src="$BATS_TEST_TMPDIR/src" + git init -q --initial-branch=main "$src" + mkdir -p "$src/updates.d" + printf '%s\n' '#!/bin/bash' 'echo update' > "$src/update" + chmod +x "$src/update" + touch "$src/updates.d/.keep" + printf '%s\n' '#!/bin/bash' 'install_packages() { echo "install_packages $*" >> "$STUB_LOG"; }' \ + > "$src/utils.sh" + printf 'check:\n\ttrue\n' > "$src/Makefile" + git -C "$src" add -A + git -C "$src" commit -qm "CoreDesktop" + git clone -q --bare "$src" "$UPSTREAM" + rm -rf "$src" + git clone -q "$UPSTREAM" "$DEPLOYED" +} + +# Clone the working copy and point the settings at it +make_working_copy() { + git clone -q "$UPSTREAM" "$WC" + mkdir -p "$HOME/.config/coredesktop-dev" + printf 'WORKING_COPY=%q\n' "$WC" > "$HOME/.config/coredesktop-dev/config" +} + +# Run the coredesktop-dev command +cdd() { + "$PROJECT/bin/coredesktop-dev" "$@" +} + +# stub NAME [BODY]: put a fake NAME on PATH that appends "NAME args..." to +# $STUB_LOG and then runs BODY, a bash snippet that sees the args as "$@" +stub() { + local name="$1" body="${2:-}" + cat > "$STUB_BIN/$name" << EOF +#!/bin/bash +echo "$name \$*" >> "\$STUB_LOG" +$body +EOF + chmod +x "$STUB_BIN/$name" +} + +# called NAME ARGS...: true if the stub log has exactly the line "NAME ARGS..." +called() { + grep -qxF "$*" "$STUB_LOG" +} + +# not_called NAME: true if the stub NAME never ran +not_called() { + ! grep -q "^$1 " "$STUB_LOG" +} + +# uncalled NAME ARGS...: the opposite of called. (A bare "! called ..." would +# not fail the test: bats, like set -e, ignores the status of a ! command.) +uncalled() { + ! called "$@" +} diff --git a/tests/try.bats b/tests/try.bats new file mode 100644 index 0000000..415a100 --- /dev/null +++ b/tests/try.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + common_setup + make_working_copy + stub dbus-run-session '[ "$1" = -- ] && shift; exec "$@"' + export DISPLAY=:0 WAYLAND_DISPLAY=wayland-0 DBUS_SESSION_BUS_ADDRESS=unix:path=/nowhere SSH_AUTH_SOCK=/nowhere +} + +@test "try runs the command in a throwaway home with the working copy as CoreDesktop" { + run cdd try bash -c 'echo "home=$HOME" + echo "runtime=$XDG_RUNTIME_DIR $(stat -c %a "$XDG_RUNTIME_DIR")" + echo "display=${DISPLAY-unset} ${WAYLAND_DISPLAY-unset} ${DBUS_SESSION_BUS_ADDRESS-unset} ${SSH_AUTH_SOCK-unset}" + echo "coredesktop=$(readlink ~/.local/share/coredesktop)" + echo "update=$(readlink "$(command -v update)")" + echo "config=$XDG_CONFIG_HOME"' + [ "$status" -eq 0 ] + [[ "$output" == *"home=$TMPDIR/coredesktop-dev-try."*"/home"* ]] + [[ "$output" == *"runtime=$TMPDIR/coredesktop-dev-try."*"/runtime 700"* ]] + [[ "$output" == *"display=unset unset unset unset"* ]] + [[ "$output" == *"coredesktop=$WC"* ]] + [[ "$output" == *"update=$WC/update"* ]] + [[ "$output" == *"config=$TMPDIR/coredesktop-dev-try."*"/home/.config"* ]] + [[ "$output" == *"still change this machine"* ]] + grep -q "^dbus-run-session -- bash" "$STUB_LOG" +} + +@test "try cleans up afterwards, and passes on the command's exit status" { + run cdd try bash -c 'exit 4' + [ "$status" -eq 4 ] + [ -z "$(ls "$TMPDIR")" ] +} + +@test "try --keep keeps the throwaway folder" { + run cdd try --keep true + [ "$status" -eq 0 ] + [[ "$output" == *"Kept $TMPDIR/coredesktop-dev-try."* ]] + [ -n "$(ls "$TMPDIR")" ] +} + +@test "try doesn't touch the real home" { + run cdd try bash -c 'touch ~/made-by-try' + [ "$status" -eq 0 ] + [ ! -e "$HOME/made-by-try" ] +} diff --git a/tests/update.bats b/tests/update.bats new file mode 100644 index 0000000..080636c --- /dev/null +++ b/tests/update.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +load test_helper + +HOOK_MARKER="# Installed by coredesktop-dev; remove with: coredesktop-dev hooks remove" + +@test "update installs the packages and links the command, without asking anything" { + run bash "$PROJECT/update.sh" < /dev/null + [ "$status" -eq 0 ] + called install_packages shellcheck bats tea qemu-base libisoburn + [ "$(readlink "$HOME/.local/bin/coredesktop-dev")" = "$PROJECT/bin/coredesktop-dev" ] +} + +@test "update fixes a link that points somewhere else" { + mkdir -p "$HOME/.local/bin" + ln -s /nowhere "$HOME/.local/bin/coredesktop-dev" + + run bash "$PROJECT/update.sh" < /dev/null + [ "$status" -eq 0 ] + [ "$(readlink "$HOME/.local/bin/coredesktop-dev")" = "$PROJECT/bin/coredesktop-dev" ] +} + +@test "update leaves an unchanged pre-push hook alone" { + make_working_copy + cp "$PROJECT/hooks/pre-push" "$WC/.git/hooks/pre-push" + touch -d '2020-01-01' "$WC/.git/hooks/pre-push" + + run bash "$PROJECT/update.sh" < /dev/null + [ "$status" -eq 0 ] + [ "$(date -r "$WC/.git/hooks/pre-push" +%Y)" = 2020 ] + [[ "$output" != *"Updated the pre-push hook"* ]] +} + +@test "update refreshes our pre-push hook when it has changed" { + make_working_copy + printf '%s\n' '#!/bin/bash' "$HOOK_MARKER" 'old version' > "$WC/.git/hooks/pre-push" + + run bash "$PROJECT/update.sh" < /dev/null + [ "$status" -eq 0 ] + cmp "$PROJECT/hooks/pre-push" "$WC/.git/hooks/pre-push" + [[ "$output" == *"Updated the pre-push hook"* ]] +} + +@test "update doesn't touch someone else's pre-push hook" { + make_working_copy + printf '%s\n' '#!/bin/bash' 'mine' > "$WC/.git/hooks/pre-push" + + run bash "$PROJECT/update.sh" < /dev/null + [ "$status" -eq 0 ] + [ "$(tail -n 1 "$WC/.git/hooks/pre-push")" = mine ] +} diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..0eff59d --- /dev/null +++ b/update.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Purpose : Keep coredesktop-dev current (run by CoreDesktop's `update`). +# Never asks anything. + +set -e + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COREDESKTOP="$HOME/.local/share/coredesktop" + +source "$COREDESKTOP/utils.sh" +source "$SELF/lib/common.sh" + +PACKAGES=( + shellcheck # make lint + bats # make test + tea # Gitea's CLI, for pull requests + qemu-base # the smoketest's virtual machines + libisoburn # xorriso, for the smoketest VM's cloud-init disk +) + +echo "Checking coredesktop-dev's packages..." +install_packages "${PACKAGES[@]}" + +# A symlink, so each pull updates the command +mkdir -p "$HOME/.local/bin" +if [ "$(readlink "$HOME/.local/bin/coredesktop-dev")" != "$SELF/bin/coredesktop-dev" ]; then + ln -sfn "$SELF/bin/coredesktop-dev" "$HOME/.local/bin/coredesktop-dev" + echo "Linked ~/.local/bin/coredesktop-dev" +fi + +# Refresh the working copy's pre-push hook, if it's ours and has changed +load_config +if [ -n "$WORKING_COPY" ] && is_coredesktop_clone "$WORKING_COPY"; then + source "$SELF/lib/hooks.sh" + hook="$(hook_path)" + if hook_is_ours "$hook" && ! cmp -s "$SELF/hooks/pre-push" "$hook"; then + install -m 755 "$SELF/hooks/pre-push" "$hook" + echo "Updated the pre-push hook in $WORKING_COPY" + fi +fi diff --git a/vm/provision.sh b/vm/provision.sh new file mode 100644 index 0000000..f561ec4 --- /dev/null +++ b/vm/provision.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# +# Purpose : Turn Arch's cloud image into the machine CoreDesktop's README starts +# from: Arch with archinstall's desktop profile and GNOME. Run as root +# in the VM while building the smoketest's base image. +# +# Differences from a real archinstall machine, on purpose: the user (tester) +# logs in to GNOME automatically and has passwordless sudo, so the smoketest +# runs without anyone at the keyboard. + +set -euo pipefail + +USER_NAME=tester + +echo "== Growing / to fill the disk" +btrfs filesystem resize max / || echo "Couldn't grow /; continuing." +df -h / + +echo "== Sharing the host's package cache" +if ! grep -q '^pkgcache ' /etc/fstab; then + echo 'pkgcache /var/cache/pacman/pkg 9p trans=virtio,version=9p2000.L,msize=524288,nofail 0 0' >> /etc/fstab +fi +systemctl daemon-reload +mountpoint -q /var/cache/pacman/pkg || mount /var/cache/pacman/pkg \ + || echo "No shared package cache; packages download into the VM." + +echo "== Installing GNOME" +pacman -Sy --needed --noconfirm archlinux-keyring +# archinstall's desktop profile with GNOME, NetworkManager and PipeWire +pacman -Su --needed --noconfirm \ + gnome gnome-tweaks gdm \ + networkmanager \ + pipewire pipewire-alsa pipewire-pulse pipewire-jack wireplumber \ + base-devel git sudo openssh nano vim htop wget smartmontools xdg-utils + +echo "== Locale" +sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen +locale-gen +echo LANG=en_US.UTF-8 > /etc/locale.conf + +echo "== NetworkManager instead of systemd-networkd" +systemctl disable systemd-networkd.service systemd-networkd.socket systemd-resolved.service || true +# NetworkManager writes its own resolv.conf; the cloud image's points at +# systemd-resolved +rm -f /etc/resolv.conf +systemctl enable NetworkManager.service gdm.service sshd.service + +echo "== Automatic login to GNOME as $USER_NAME" +cat > /etc/gdm/custom.conf << EOF +[daemon] +AutomaticLoginEnable=True +AutomaticLogin=$USER_NAME +EOF + +echo "== Smoketest folder" +install -d -o "$USER_NAME" -g "$USER_NAME" /srv/smoketest + +# cloud-init has done its job (the user, their SSH key and sudo rights) +touch /etc/cloud/cloud-init.disabled + +echo "== Done" diff --git a/vm/user-data b/vm/user-data new file mode 100644 index 0000000..b83804a --- /dev/null +++ b/vm/user-data @@ -0,0 +1,17 @@ +#cloud-config +# The smoketest VM's user. coredesktop-dev puts its SSH key in place of @SSH_KEY@. +users: + - name: tester + shell: /bin/bash + groups: [wheel] + sudo: "ALL=(ALL) NOPASSWD:ALL" + lock_passwd: false + ssh_authorized_keys: + - @SSH_KEY@ +# For logging in at the console (SMOKETEST_VNC) when something goes wrong +chpasswd: + expire: false + users: + - name: tester + password: tester + type: text