diff --git a/Makefile b/Makefile
index 9091d4d..7390aea 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ LDFLAGS := -ldflags "\
-X main.date=$(DATE) \
"
-.PHONY: build test race vet fmt-check tidy-check staticcheck actionlint lint vulnerability verify clean release hardware-test vm-e2e vm-e2e-matrix vm-e2e-purge vm-lab-cleanup
+.PHONY: build test race vet fmt-check tidy-check staticcheck actionlint lint vulnerability verify clean release hardware-test macos-lab-test macos-e2e vm-e2e vm-e2e-matrix vm-e2e-purge vm-lab-cleanup
build:
go build $(LDFLAGS) -o $(BINARY) .
@@ -67,6 +67,12 @@ release:
hardware-test:
./tests/hardware/run.sh
+macos-lab-test:
+ ./tests/hardware/run-macos-lab.sh
+
+macos-e2e:
+ ./tests/e2e/run-macos-lab.sh
+
VM ?= appimage
vm-e2e:
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index b39716d..b64ab1c 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -53,6 +53,14 @@ passes the original command and arguments to `/usr/bin/sudo`. The `pkexec` file
is restored by the evidence trap even on failure. A real desktop session
remains the appropriate test for the graphical PolicyKit dialog itself.
+The physical Apple Silicon lane runs the same release-shaped portable contract,
+attended PTY dashboard/management journey, and unattended lifecycle on native
+Darwin ARM64. It begins from the lab's already-ready `omnideck-runtime` Podman
+machine, so it deliberately fails if the CLI enters Podman installation or
+machine-creation screens. The ARM64 CLI and fixture image are built on the
+Linux controller and copied to the Mac; only execution and native Podman work
+consume the resource-constrained host.
+
## Run before a release
The external lab path stays machine-local:
@@ -61,6 +69,7 @@ The external lab path stays machine-local:
export OMNIDECK_VM_LAB_DIR=/absolute/path/to/omnideck-release-lab
make vm-e2e
make vm-e2e-matrix YES=1
+make macos-e2e
```
The default lane is the Ubuntu `appimage` guest. The command requires that guest
@@ -74,6 +83,12 @@ make vm-e2e VM=rpm
make vm-e2e VM=windows
```
+`make macos-e2e` leases `macos-arm64`, selects unused `omnideckN` and loopback
+ports at runtime, and records evidence under `artifacts/cli/macos-e2e/`. Its
+`runtime-ready` reset removes only resources named by the lab ownership marker;
+the user's normal CLI, config, container, volumes, and warm Podman machine are
+preserved.
+
`vm-e2e-matrix` is the canonical complete command. Override its deterministic
order with `LANES=appimage,windows`. Every lane runs the `release-clean`
preflight. The pinned dev-container image builds reproducible archives into a
@@ -132,15 +147,18 @@ make vm-lab-cleanup ALL=1 APPLY=1
## Scope
-The automated lanes cover mutable x64 Linux guests and the local Windows 11 x64
-guest. They do not claim stable-to-candidate production-image upgrades,
-backup/restore, macOS installation prompts, subjective visual quality, or the
-Windows **restart now** auto-reopen path. Run the checked-in manual procedures
-for those release-candidate behaviors.
+The automated lanes cover mutable x64 Linux guests, the local Windows 11 x64
+guest, and native CLI/TUI execution on the physical Apple Silicon host. They do
+not claim stable-to-candidate production-image upgrades, backup/restore, macOS
+Podman installation prompts, subjective visual quality, or the Windows
+**restart now** auto-reopen path. Run the checked-in manual procedures for
+those release-candidate behaviors.
The terminal driver's dependency-free parser has a fast host check:
```sh
python3 -m unittest tests/e2e/test_terminal_driver.py
-bash -n tests/e2e/run.sh tests/e2e/run-windows.sh tests/e2e/guest.sh
+python3 -m unittest tests/e2e/test_lab_contract.py
+bash -n tests/e2e/run.sh tests/e2e/run-windows.sh tests/e2e/guest.sh \
+ tests/e2e/run-macos-lab.sh tests/e2e/macos_guest.sh
```
diff --git a/tests/e2e/macos_guest.sh b/tests/e2e/macos_guest.sh
new file mode 100755
index 0000000..05cbcad
--- /dev/null
+++ b/tests/e2e/macos_guest.sh
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+export LANG=C
+export LC_ALL=C
+
+work_dir="${1:?guest work directory is required}"
+expected_version="${2:?expected version is required}"
+fixture_image="${3:?fixture image is required}"
+instance=''
+test_instance_selected=0
+web_port=2337
+additional_name=omnideck2
+additional_port=2338
+registry_port=46864
+registry_container="omnideck-hw-macos-e2e-registry"
+ownership_marker="$HOME/.omnideck-lab/state/cli-e2e-instance"
+published_image="localhost:${registry_port}/${fixture_image#localhost/}"
+result_dir="$work_dir/results"
+archive="$work_dir/omnideck-darwin-arm64.tar.gz"
+checksum_file="$work_dir/SHA256SUMS"
+binary="$work_dir/bin/omnideck"
+config_dir="$work_dir/config"
+registries_conf="$work_dir/registries.conf"
+started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+current_step=initialization
+test_status=failed
+
+mkdir -p "$result_dir" "$work_dir/extracted" "$work_dir/bin" "$config_dir"
+exec > >(tee -a "$result_dir/guest.log") 2>&1
+
+inventory() {
+ local suffix="$1"
+ {
+ printf 'timestamp=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ printf 'os=%s\narchitecture=%s\n' "$(sw_vers -productVersion)" "$(uname -m)"
+ podman --version
+ podman machine list --format json
+ podman ps --all --format 'container={{.Names}}|{{.Status}}' | sort
+ podman volume ls --format 'volume={{.Name}}' | sort
+ } > "$result_dir/inventory-$suffix.txt"
+}
+
+cleanup_resources() {
+ if [[ "$test_instance_selected" == 1 && "$instance" =~ ^omnideck[0-9]*$ ]]; then
+ podman rm -f "$instance" >/dev/null 2>&1 || true
+ podman volume rm -f "$instance-home" "$instance-state" >/dev/null 2>&1 || true
+ fi
+ rm -f -- "$ownership_marker"
+ podman rm -f "$registry_container" >/dev/null 2>&1 || true
+ podman rmi -f "$published_image" "$fixture_image" >/dev/null 2>&1 || true
+ rm -rf -- "$config_dir"
+}
+
+write_evidence() {
+ local exit_code=$?
+ set +e
+ [[ ! -d "$config_dir" ]] || tar -czf "$result_dir/config-on-exit.tar.gz" -C "$config_dir" .
+ cleanup_resources
+ inventory after
+ printf '{\n "status": "%s",\n "lastStep": "%s",\n "expectedVersion": "%s",\n "fixtureImage": "%s",\n "platform": "darwin",\n "architecture": "arm64",\n "podmanSetup": "excluded-ready-runtime",\n "startedAt": "%s",\n "finishedAt": "%s"\n}\n' \
+ "$test_status" "$current_step" "$expected_version" "$fixture_image" \
+ "$started_at" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$result_dir/summary.json"
+ if [[ "$test_status" == passed ]]; then
+ printf '%s\n' '' \
+ '' \
+ > "$result_dir/junit.xml"
+ else
+ printf '%s\n' '' \
+ "" \
+ > "$result_dir/junit.xml"
+ fi
+ exit "$exit_code"
+}
+trap write_evidence EXIT
+trap 'printf "ERROR step=%s line=%s command=%q\\n" "$current_step" "$LINENO" "$BASH_COMMAND" >&2' ERR
+
+current_step='ready-runtime precondition'
+[[ "$(uname -s)/$(uname -m)" == Darwin/arm64 ]]
+podman info >/dev/null
+inventory before
+for suffix in '' $(seq 2 9999); do
+ candidate="omnideck${suffix}"
+ if ! podman container inspect "$candidate" >/dev/null 2>&1; then instance="$candidate"; break; fi
+done
+[[ -n "$instance" ]]
+test_instance_selected=1
+mkdir -p "$(dirname "$ownership_marker")"
+printf '%s\n' "$instance" > "$ownership_marker"
+for suffix in '' $(seq 2 9999); do
+ candidate="omnideck${suffix}"
+ [[ "$candidate" == "$instance" ]] && continue
+ if ! podman container inspect "$candidate" >/dev/null 2>&1; then additional_name="$candidate"; break; fi
+done
+web_port="$(python3 - <<'PY'
+import socket
+for port in range(2337, 65536):
+ sock=socket.socket()
+ try: sock.bind(('127.0.0.1', port))
+ except OSError: continue
+ finally: sock.close()
+ print(port); break
+PY
+)"
+additional_port="$(python3 - "$web_port" <<'PY'
+import socket, sys
+for port in range(int(sys.argv[1]) + 1, 65536):
+ sock=socket.socket()
+ try: sock.bind(('127.0.0.1', port))
+ except OSError: continue
+ finally: sock.close()
+ print(port); break
+PY
+)"
+[[ "$instance" =~ ^omnideck[0-9]*$ && "$additional_name" =~ ^omnideck[0-9]*$ ]]
+[[ "$web_port" =~ ^[0-9]+$ && "$additional_port" =~ ^[0-9]+$ ]]
+printf 'selectedInstance=%s\nselectedPort=%s\nadditionalName=%s\nadditionalPort=%s\n' \
+ "$instance" "$web_port" "$additional_name" "$additional_port" > "$result_dir/isolation.txt"
+[[ ! -e "$config_dir/instances/$instance.yaml" ]]
+
+current_step='install release archive'
+(cd "$work_dir" && shasum -a 256 --check "$(basename "$checksum_file")")
+tar -xzf "$archive" -C "$work_dir/extracted"
+install -m 0755 "$work_dir/extracted/omnideck" "$binary"
+"$binary" --version | tee "$result_dir/version.txt"
+grep -Fq "omnideck version $expected_version" "$result_dir/version.txt"
+"$binary" install --help > "$result_dir/install-help.txt"
+grep -Fq 'Walks through setting up one Omnideck instance.' "$result_dir/install-help.txt"
+grep -Fq 'add, install, setup' "$result_dir/install-help.txt"
+
+current_step='portable CLI contract'
+tar -xzf "$work_dir/contracts.tar.gz" -C "$work_dir"
+"$work_dir/releasecontract" \
+ --binary "$binary" --mode portable --expected-version "$expected_version" \
+ --expected-os darwin --expected-arch arm64 --contracts "$work_dir/contracts" \
+ --report "$result_dir/portable-contract.json" --junit "$result_dir/portable-contract.xml"
+
+current_step='load ARM64 fixture image'
+podman load --input "$work_dir/fixture.tar" | tee "$result_dir/fixture-load.txt"
+printf '[[registry]]\nlocation = "localhost:%s"\ninsecure = true\n' "$registry_port" > "$registries_conf"
+podman run -d --name "$registry_container" -p "127.0.0.1:${registry_port}:5000" \
+ docker.io/library/registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
+for _ in $(seq 1 30); do
+ curl --fail --silent --max-time 2 "http://127.0.0.1:${registry_port}/v2/" >/dev/null && break
+ sleep 1
+done
+curl --fail --silent --show-error --max-time 2 "http://127.0.0.1:${registry_port}/v2/" >/dev/null
+podman tag "$fixture_image" "$published_image"
+podman push --tls-verify=false "$published_image"
+
+current_step='guided install journey with Podman setup excluded'
+python3 "$work_dir/terminal_driver.py" macos-install \
+ --binary "$binary" --config-dir "$config_dir" --registries-conf "$registries_conf" \
+ --fixture-image "$published_image" --artifact-dir "$result_dir" \
+ --instance-name "$instance" --web-port "$web_port" \
+ --additional-name "$additional_name" --additional-port "$additional_port"
+
+current_step='installed behavior'
+env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \
+ "$binary" --no-color --name "$instance" status | tee "$result_dir/status.txt"
+grep -Fq running "$result_dir/status.txt"
+curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:$web_port" > "$result_dir/web-ui.html"
+grep -Fq 'omnideck hardware fixture ready' "$result_dir/web-ui.html"
+podman container inspect "$instance" > "$result_dir/container-inspect.json"
+podman volume inspect "$instance-home" "$instance-state" > "$result_dir/volume-inspect.json"
+grep -Eq '^runtime:[[:space:]]+podman$' "$config_dir/settings.yaml"
+grep -Eq "^container_name:[[:space:]]+$instance$" "$config_dir/instances/$instance.yaml"
+grep -Fq "image: $published_image" "$config_dir/instances/$instance.yaml"
+
+current_step='unattended command and update contract'
+env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \
+ "$binary" --json list > "$result_dir/list.json"
+env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \
+ "$binary" --json --name "$instance" status > "$result_dir/status.json"
+python3 - "$result_dir/list.json" "$result_dir/status.json" "$instance" <<'PY'
+import json, sys
+instances=json.load(open(sys.argv[1], encoding='utf-8'))
+status=json.load(open(sys.argv[2], encoding='utf-8'))
+assert len(instances) == 1 and instances[0]['name'] == sys.argv[3], instances
+assert status['container'] == sys.argv[3] and status['status'] == 'running', status
+PY
+podman exec "$instance" sh -c 'printf "%s\n" update-volume-marker > /home/omnideck/update-volume-marker'
+env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \
+ "$binary" --no-color --name "$instance" config set memory 512m | tee "$result_dir/config-set.txt"
+grep -Fq 'Set memory = 512m' "$result_dir/config-set.txt"
+env OMNIDECK_CONFIG_DIR="$config_dir" CONTAINERS_REGISTRIES_CONF="$registries_conf" \
+ "$binary" --no-color --name "$instance" update --plain | tee "$result_dir/update-plain.txt"
+grep -Fq "Omnideck is up to date: http://localhost:$web_port" "$result_dir/update-plain.txt"
+[[ "$(podman exec "$instance" cat /home/omnideck/update-volume-marker)" == update-volume-marker ]]
+
+current_step='TUI management journey'
+python3 "$work_dir/terminal_driver.py" manage \
+ --binary "$binary" --config-dir "$config_dir" --registries-conf "$registries_conf" \
+ --fixture-image "$published_image" --artifact-dir "$result_dir" \
+ --instance-name "$instance" --web-port "$web_port" \
+ --additional-name "$additional_name" --additional-port "$additional_port"
+
+current_step='removal cleanup contract'
+! podman container inspect "$instance" >/dev/null 2>&1
+! podman volume inspect "$instance-home" >/dev/null 2>&1
+! podman volume inspect "$instance-state" >/dev/null 2>&1
+[[ ! -e "$config_dir/instances/$instance.yaml" ]]
+
+current_step='unattended CLI lifecycle'
+podman rm -f "$registry_container" >/dev/null
+chmod +x "$work_dir/hardware-run.sh"
+OMNIDECK_HARDWARE_CLI="$binary" \
+OMNIDECK_HARDWARE_ENGINE=podman \
+OMNIDECK_HARDWARE_REGISTRY_PORT=46864 \
+OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE="$work_dir/fixture.tar" \
+OMNIDECK_HARDWARE_ARCHIVE_IMAGE="$fixture_image" \
+OMNIDECK_HARDWARE_OUTPUT_DIR="$result_dir/unattended" \
+ "$work_dir/hardware-run.sh"
+python3 - "$result_dir/unattended/summary.json" <<'PY'
+import json, sys
+result=json.load(open(sys.argv[1], encoding='utf-8'))
+assert result['status'] == 'passed', result
+PY
+
+current_step=complete
+test_status=passed
+printf 'PASS: macOS portable, ready-runtime attended TUI, management, and unattended CLI journeys completed.\n'
diff --git a/tests/e2e/run-macos-lab.sh b/tests/e2e/run-macos-lab.sh
new file mode 100755
index 0000000..e6bc672
--- /dev/null
+++ b/tests/e2e/run-macos-lab.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$script_dir/../.." && pwd)"
+lab_dir="${OMNIDECK_VM_LAB_DIR:-}"
+target=macos-arm64
+profile="${OMNIDECK_VM_LAB_PROFILE:-release-clean}"
+builder_image="${OMNIDECK_CLI_BUILDER_IMAGE:-omnideck-cli-builder:local}"
+
+[[ -n "$lab_dir" && -x "$lab_dir/lab.sh" ]] || {
+ printf 'Set OMNIDECK_VM_LAB_DIR to the deployed OmniDeck VM lab.\n' >&2
+ exit 2
+}
+
+if [[ "${OMNIDECK_VM_LAB_LEASED:-}" != 1 ]]; then
+ "$lab_dir/lab.sh" preflight cli "$profile" --lanes "$target" >/dev/null
+ command -v docker >/dev/null 2>&1 || { printf 'Docker is required to build macOS ARM64 artifacts off-host.\n' >&2; exit 2; }
+ docker image inspect "$builder_image" >/dev/null 2>&1 || {
+ printf 'Builder image %q is unavailable. Build .devcontainer/Dockerfile first.\n' "$builder_image" >&2
+ exit 2
+ }
+
+ fixture_key="$(shasum -a 256 "$repo_root/tests/hardware/fixture/Containerfile" "$repo_root/tests/hardware/fixture/index.html" | shasum -a 256 | awk '{print substr($1,1,20)}')"
+ fixture_cache="$($lab_dir/lab.sh cache-path cli "macos-arm64-fixture-${fixture_key}")"
+ fixture_archive="$fixture_cache/fixture.tar"
+ fixture_image="localhost/omnideck-hardware-fixture:macos-arm64-${fixture_key}"
+ if [[ ! -f "$fixture_archive" ]]; then
+ mkdir -p "$fixture_cache"
+ temporary_archive="$fixture_cache/.fixture.tar.$$"
+ docker buildx build --platform linux/arm64 \
+ --file "$repo_root/tests/hardware/fixture/Containerfile" --tag "$fixture_image" \
+ --output "type=docker,dest=${temporary_archive}" "$repo_root/tests/hardware/fixture"
+ mv -- "$temporary_archive" "$fixture_archive"
+ shasum -a 256 "$fixture_archive" > "$fixture_cache/SHA256SUMS"
+ fi
+
+ run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
+ source_commit="$(git -C "$repo_root" rev-parse HEAD)"
+ source_dirty=false
+ [[ -z "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=normal)" ]] || source_dirty=true
+ output_dir="$($lab_dir/lab.sh artifact-path cli macos-e2e "$run_id")"
+ build_dir="$output_dir/build"
+ builder_cache="$($lab_dir/lab.sh cache-path cli macos-go-builder-v1)"
+ mkdir -p "$build_dir/archive"
+ mkdir -p "$builder_cache/gocache" "$builder_cache/gopath"
+ short_commit="${source_commit:0:12}"
+ expected_version="vm-e2e-${short_commit}"
+ ldflags="-X main.version=${expected_version} -X main.commit=${short_commit} -X main.date=vm-e2e"
+
+ docker run --rm --entrypoint /bin/zsh \
+ --user "$(id -u):$(id -g)" \
+ --env GOOS=darwin --env GOARCH=arm64 --env CGO_ENABLED=0 \
+ --env GOCACHE=/tmp/omnideck-go-build --env GOPATH=/tmp/omnideck-go \
+ --volume "$repo_root:/workspace:ro" --volume "$build_dir:/out" \
+ --volume "$builder_cache/gocache:/tmp/omnideck-go-build" \
+ --volume "$builder_cache/gopath:/tmp/omnideck-go" \
+ --workdir /workspace "$builder_image" \
+ -c "go build -trimpath -buildvcs=false -ldflags \"${ldflags}\" -o /out/archive/omnideck . && go build -trimpath -buildvcs=false -o /out/releasecontract ./tests/releasecontract"
+ chmod +x "$build_dir/archive/omnideck" "$build_dir/releasecontract"
+ file "$build_dir/archive/omnideck" "$build_dir/releasecontract" | grep -Eq 'Mach-O 64-bit.*arm64'
+ tar -czf "$build_dir/omnideck-darwin-arm64.tar.gz" -C "$build_dir/archive" omnideck
+ (cd "$build_dir" && shasum -a 256 omnideck-darwin-arm64.tar.gz > SHA256SUMS)
+ tar --sort=name --mtime="@$(git -C "$repo_root" show -s --format=%ct HEAD)" \
+ --owner=0 --group=0 --numeric-owner -cf - -C "$repo_root" contracts | gzip -n > "$build_dir/contracts.tar.gz"
+
+ "$lab_dir/lab.sh" evidence-init "$output_dir" cli macos-e2e "$run_id" \
+ "$source_commit" "$target" runtime-ready "sourceDirty=$source_dirty" \
+ "architecture=arm64" "podmanSetup=excluded-ready-runtime" "driver=pty"
+ trap '"$lab_dir/lab.sh" evidence-finish "$output_dir" failed >/dev/null 2>&1 || true' EXIT
+ status=0
+ "$lab_dir/lab.sh" lease "$target" cli-e2e "$run_id" --cleanup-baseline runtime-ready -- env \
+ OMNIDECK_MACOS_CLI_BUILD_DIR="$build_dir" \
+ OMNIDECK_MACOS_FIXTURE_ARCHIVE="$fixture_archive" \
+ OMNIDECK_MACOS_FIXTURE_IMAGE="$fixture_image" \
+ OMNIDECK_MACOS_EXPECTED_VERSION="$expected_version" \
+ OMNIDECK_VM_LAB_OUTPUT_DIR="$output_dir" "$0" || status=$?
+ trap - EXIT
+ [[ "$status" == 0 ]] && evidence_status=passed || evidence_status=failed
+ "$lab_dir/lab.sh" evidence-finish "$output_dir" "$evidence_status"
+ printf 'Evidence: %s\n' "$output_dir"
+ exit "$status"
+fi
+
+[[ "${OMNIDECK_VM_LAB_VM:-}" == "$target" ]] || { printf 'The active lease does not own %s.\n' "$target" >&2; exit 2; }
+build_dir="${OMNIDECK_MACOS_CLI_BUILD_DIR:?Prepared macOS CLI build is required}"
+fixture_archive="${OMNIDECK_MACOS_FIXTURE_ARCHIVE:?Prepared ARM64 fixture is required}"
+fixture_image="${OMNIDECK_MACOS_FIXTURE_IMAGE:?Prepared fixture image name is required}"
+expected_version="${OMNIDECK_MACOS_EXPECTED_VERSION:?Expected CLI version is required}"
+output_dir="${OMNIDECK_VM_LAB_OUTPUT_DIR:?Lab evidence directory is required}"
+safe_run_id="$(printf '%s' "$OMNIDECK_VM_LAB_RUN_ID" | tr -cd '[:alnum:]_.-')"
+remote_root="/private/tmp/omnideck-cli-macos-e2e-${safe_run_id}"
+remote_staged=0
+
+cleanup_remote() {
+ local status=$?
+ set +e
+ if [[ "$remote_staged" == 1 ]]; then
+ case "$remote_root" in
+ /private/tmp/omnideck-cli-macos-e2e-[[:alnum:]_.-]*)
+ "$lab_dir/lab.sh" run "$target" rm -rf -- "$remote_root" >/dev/null 2>&1 || true ;;
+ *) printf 'Refusing to remove unexpected remote path: %s\n' "$remote_root" >&2; status=1 ;;
+ esac
+ fi
+ exit "$status"
+}
+trap cleanup_remote EXIT
+
+"$lab_dir/lab.sh" reset "$target" runtime-ready
+"$lab_dir/lab.sh" verify "$target"
+"$lab_dir/lab.sh" run "$target" mkdir -p "$remote_root"
+remote_staged=1
+for source in omnideck-darwin-arm64.tar.gz SHA256SUMS releasecontract contracts.tar.gz; do
+ "$lab_dir/lab.sh" copy-to "$target" "$build_dir/$source" "$remote_root/$source"
+done
+"$lab_dir/lab.sh" copy-to "$target" "$fixture_archive" "$remote_root/fixture.tar"
+"$lab_dir/lab.sh" copy-to "$target" "$script_dir/macos_guest.sh" "$remote_root/macos_guest.sh"
+"$lab_dir/lab.sh" copy-to "$target" "$script_dir/terminal_driver.py" "$remote_root/terminal_driver.py"
+"$lab_dir/lab.sh" copy-to "$target" "$repo_root/tests/hardware/run.sh" "$remote_root/hardware-run.sh"
+"$lab_dir/lab.sh" copy-to "$target" "$repo_root/tests/hardware/fixture" "$remote_root/hardware-fixture"
+"$lab_dir/lab.sh" run "$target" chmod +x "$remote_root/macos_guest.sh" \
+ "$remote_root/terminal_driver.py" "$remote_root/releasecontract" "$remote_root/hardware-run.sh"
+
+test_status=0
+"$lab_dir/lab.sh" run "$target" "$remote_root/macos_guest.sh" \
+ "$remote_root" "$expected_version" "$fixture_image" || test_status=$?
+mkdir -p "$output_dir/evidence"
+"$lab_dir/lab.sh" copy-from "$target" "$remote_root/results/." "$output_dir/evidence/" || test_status=1
+
+if [[ -f "$output_dir/evidence/summary.json" ]]; then
+ python3 - "$output_dir/evidence/summary.json" <<'PY' || test_status=1
+import json, sys
+summary=json.load(open(sys.argv[1], encoding='utf-8'))
+assert summary['status'] == 'passed', summary
+assert summary['platform'] == 'darwin' and summary['architecture'] == 'arm64', summary
+assert summary['podmanSetup'] == 'excluded-ready-runtime', summary
+PY
+fi
+exit "$test_status"
diff --git a/tests/e2e/terminal_driver.py b/tests/e2e/terminal_driver.py
index 8aefbeb..d49d44e 100755
--- a/tests/e2e/terminal_driver.py
+++ b/tests/e2e/terminal_driver.py
@@ -286,13 +286,75 @@ def install_scenario(args: argparse.Namespace) -> None:
raise AssertionError("install journey did not exit successfully")
+def macos_install_scenario(args: argparse.Namespace) -> None:
+ """Drive first-run setup with an already-bootstrapped macOS Podman machine."""
+
+ env = base_environment(args)
+ command = scenario_command(args, [args.binary, "install", "--image", args.fixture_image])
+ with TerminalSession(command, env=env, artifact_dir=args.artifact_dir, name="install") as terminal:
+ terminal.expect_all(
+ ["Welcome to omnideck", "Press Enter to set up omnideck.", "enter set up omnideck"],
+ timeout=30,
+ checkpoint="welcome",
+ )
+ mark = terminal.send(ENTER, label="enter")
+ terminal.expect_all(
+ [
+ "Preparing your environment",
+ "Setting omnideck up on this computer. This usually takes a few minutes.",
+ "Computer setup",
+ "Application files",
+ "Final checks",
+ ],
+ timeout=45,
+ since=mark,
+ checkpoint="ready-runtime-check",
+ )
+ terminal.expect_all(
+ [
+ "omnideck is ready",
+ "Everything is prepared. Open omnideck whenever you’re ready.",
+ "Open Omnideck in your browser:",
+ f"http://localhost:{args.web_port}",
+ "Your files and settings will be kept when Omnideck updates.",
+ "Press any key to return to the dashboard.",
+ ],
+ timeout=args.install_timeout,
+ since=mark,
+ checkpoint="ready",
+ fail_phrases=("The download didn’t finish", "Setup couldn’t finish"),
+ )
+ transcript = strip_terminal_controls(bytes(terminal.raw[mark:]))
+ unexpected_runtime_setup = [
+ phrase for phrase in ("Waiting for your permission", "Installing Podman", "Downloading Podman")
+ if contains_rendered_phrase(transcript, phrase)
+ ]
+ if unexpected_runtime_setup:
+ raise AssertionError(
+ f"macOS ready-runtime journey unexpectedly entered Podman setup: {unexpected_runtime_setup!r}"
+ )
+ terminal.record("checkpoint", name="podman-setup-skipped")
+ mark = terminal.send(ENTER, label="enter")
+ terminal.expect_all(
+ ["Decks", args.instance_name, f":{args.web_port}", "running", "Open UI", "Logs", "Update", "Stop"],
+ timeout=45,
+ since=mark,
+ checkpoint="first-dashboard",
+ )
+ terminal.send(b"q", label="q")
+ if terminal.wait() != 0:
+ raise AssertionError("macOS install journey did not exit successfully")
+
+
def manage_scenario(args: argparse.Namespace) -> None:
env = base_environment(args)
command = scenario_command(args, [args.binary, "tui"])
log_match_counter = f"{args.expected_log_count} of {args.expected_log_count}"
+ instance = args.instance_name
+ web_port = args.web_port
with TerminalSession(command, env=env, artifact_dir=args.artifact_dir, name="manage") as terminal:
terminal.expect_all(
- ["Decks", "omnideck", ":2337", "running", "Open UI", "Logs", "Update", "Stop"],
+ ["Decks", instance, f":{web_port}", "running", "Open UI", "Logs", "Update", "Stop"],
timeout=45,
checkpoint="dashboard",
)
@@ -307,7 +369,7 @@ def manage_scenario(args: argparse.Namespace) -> None:
mark = terminal.send(b"l", label="l")
terminal.expect_all(
- ["Decks › omnideck › Logs", "omnideck stdout + stderr", "omnideck-hardware-fixture-started", "/ search"],
+ [f"Decks › {instance} › Logs", f"{instance} stdout + stderr", "omnideck-hardware-fixture-started", "/ search"],
timeout=30,
since=mark,
checkpoint="logs",
@@ -335,44 +397,44 @@ def manage_scenario(args: argparse.Namespace) -> None:
)
mark = terminal.send(ESCAPE, label="esc")
terminal.expect_all(
- ["omnideck stdout + stderr", "esc back", "/ search"],
+ [f"{instance} stdout + stderr", "esc back", "/ search"],
timeout=15,
since=mark,
checkpoint="log-filter-cleared",
)
mark = terminal.send(ESCAPE, label="esc")
- terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="logs-back")
+ terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="logs-back")
mark = terminal.send(b"c", label="c")
terminal.expect_all(
- ["Decks › omnideck › Settings", "File storage", "App storage", "Memory limit", "Browser port", "Container image"],
+ [f"Decks › {instance} › Settings", "File storage", "App storage", "Memory limit", "Browser port", "Container image"],
timeout=20,
since=mark,
checkpoint="settings",
)
mark = terminal.send(ESCAPE, label="esc")
- terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="settings-back")
+ terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="settings-back")
mark = terminal.send(b"u", label="u")
terminal.expect_all(
- ["Decks › omnideck › Update", "Update omnideck", "files and agent data are kept", "enter update"],
+ [f"Decks › {instance} › Update", f"Update {instance}", "files and agent data are kept", "enter update"],
timeout=20,
since=mark,
checkpoint="update-review",
)
mark = terminal.send(ESCAPE, label="esc")
- terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="update-canceled")
+ terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="update-canceled")
mark = terminal.send(b"s", label="s")
terminal.expect_all(
- ["Stopped omnideck", "Start"], timeout=45, since=mark, checkpoint="stopped"
+ [f"Stopped {instance}", "Start"], timeout=45, since=mark, checkpoint="stopped"
)
mark = terminal.send(b"d", label="d")
terminal.expect_all(
[
"Doctor",
"1 problem needs attention",
- "Omnideck instance — omnideck is stopped",
+ f"Omnideck instance — {instance} is stopped",
"Press Enter to start omnideck.",
],
timeout=45,
@@ -381,34 +443,34 @@ def manage_scenario(args: argparse.Namespace) -> None:
)
mark = terminal.send(ENTER, label="enter")
terminal.expect_all(
- ["Everything required is working", "Omnideck instance — omnideck is running", "Browser — http://localhost:2337 is responding"],
+ ["Everything required is working", f"Omnideck instance — {instance} is running", f"Browser — http://localhost:{web_port} is responding"],
timeout=60,
since=mark,
checkpoint="doctor-recovered",
)
mark = terminal.send(ESCAPE, label="esc")
- terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="doctor-back")
+ terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="doctor-back")
mark = terminal.send(b"n", label="n")
terminal.expect_all(
- ["Setup · Settings", "Recommended settings are ready", "omnideck2", "http://localhost:2338"],
+ ["Setup · Settings", "Recommended settings are ready", args.additional_name, f"http://localhost:{args.additional_port}"],
timeout=45,
since=mark,
checkpoint="additional-instance-defaults",
)
mark = terminal.send(ENTER, label="enter")
terminal.expect_all(
- ["Setup · Review", "Ready to set up Omnideck", "Name", "omnideck2", "Press Enter to start setup"],
+ ["Setup · Review", "Ready to set up Omnideck", "Name", args.additional_name, "Press Enter to start setup"],
timeout=20,
since=mark,
checkpoint="additional-instance-review",
)
mark = terminal.send(b"q", label="q")
- terminal.expect_all(["Decks", "omnideck", "running"], timeout=20, since=mark, checkpoint="additional-instance-canceled")
+ terminal.expect_all(["Decks", instance, "running"], timeout=20, since=mark, checkpoint="additional-instance-canceled")
mark = terminal.send(b"x", label="x")
terminal.expect_all(
- ["Remove omnideck", "Keep saved data — Recommended", "Permanently delete saved data"],
+ [f"Remove {instance}", "Keep saved data — Recommended", "Permanently delete saved data"],
timeout=20,
since=mark,
checkpoint="removal-data-choice",
@@ -424,15 +486,15 @@ def manage_scenario(args: argparse.Namespace) -> None:
terminal.send(DOWN, label="down")
mark = terminal.send(ENTER, label="enter")
terminal.expect_all(
- ["Confirm permanent data deletion", "Type omnideck below to confirm", "No backup will be created"],
+ ["Confirm permanent data deletion", f"Type {instance} below to confirm", "No backup will be created"],
timeout=20,
since=mark,
checkpoint="removal-confirmation",
)
- terminal.send(b"omnideck", label="omnideck")
+ terminal.send(instance.encode(), label=instance)
mark = terminal.send(ENTER, label="enter")
terminal.expect_all(
- ["omnideck was removed", "saved data was permanently deleted", "Press any key to return to Decks"],
+ [f"{instance} was removed", "saved data was permanently deleted", "Press any key to return to Decks"],
timeout=60,
since=mark,
checkpoint="removal-complete",
@@ -558,7 +620,7 @@ def windows_install_scenario(args: argparse.Namespace) -> None:
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser()
- parser.add_argument("scenario", choices=("install", "manage", "windows-bootstrap", "windows-install"))
+ parser.add_argument("scenario", choices=("install", "macos-install", "manage", "windows-bootstrap", "windows-install"))
parser.add_argument("--binary", required=True)
parser.add_argument("--config-dir", required=True)
parser.add_argument("--registries-conf", required=True)
@@ -566,6 +628,10 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
parser.add_argument("--artifact-dir", required=True, type=Path)
parser.add_argument("--install-timeout", type=float, default=1200)
parser.add_argument("--expected-log-count", type=int, default=1)
+ parser.add_argument("--instance-name", default="omnideck")
+ parser.add_argument("--web-port", default="2337")
+ parser.add_argument("--additional-name", default="omnideck2")
+ parser.add_argument("--additional-port", default="2338")
parser.add_argument("--command-json")
parser.add_argument("--hook-command-json")
return parser.parse_args(argv)
@@ -577,6 +643,8 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
if args.scenario == "install":
install_scenario(args)
+ elif args.scenario == "macos-install":
+ macos_install_scenario(args)
elif args.scenario == "manage":
manage_scenario(args)
elif args.scenario == "windows-bootstrap":
diff --git a/tests/e2e/test_lab_contract.py b/tests/e2e/test_lab_contract.py
index 86bf6f1..5a7bd4b 100644
--- a/tests/e2e/test_lab_contract.py
+++ b/tests/e2e/test_lab_contract.py
@@ -37,6 +37,21 @@ def test_matrix_preflights_and_groups_lane_evidence(self) -> None:
self.assertIn('OMNIDECK_VM_E2E_OUTPUT_DIR="$lane_dir"', script)
self.assertIn("lane-status.tsv", script)
+ def test_macos_harness_uses_physical_host_contract_and_ready_runtime(self) -> None:
+ script = (ROOT / "tests/e2e/run-macos-lab.sh").read_text(encoding="utf-8")
+ guest = (ROOT / "tests/e2e/macos_guest.sh").read_text(encoding="utf-8")
+ self.assertIn('lease "$target" cli-e2e', script)
+ self.assertIn("artifact-path cli macos-e2e", script)
+ self.assertIn("GOOS=darwin", script)
+ self.assertIn("GOARCH=arm64", script)
+ self.assertIn("--cleanup-baseline runtime-ready", script)
+ self.assertIn("podmanSetup=excluded-ready-runtime", script)
+ self.assertIn("terminal_driver.py\" macos-install", guest)
+ self.assertIn("terminal_driver.py\" manage", guest)
+ self.assertIn("ownership_marker", guest)
+ self.assertNotIn("podman machine init", guest)
+ self.assertNotIn("podman machine start", guest)
+
def test_legacy_manual_helper_delegates_to_canonical_lane(self) -> None:
script = (ROOT / "tests/manual/run-local-hardware.sh").read_text(
encoding="utf-8"
diff --git a/tests/hardware/README.md b/tests/hardware/README.md
index 2dda701..d280147 100644
--- a/tests/hardware/README.md
+++ b/tests/hardware/README.md
@@ -63,6 +63,25 @@ macOS or Linux:
OMNIDECK_HARDWARE_ENGINE=auto ./tests/hardware/run.sh
```
+The external release lab can lease its configured Apple Silicon host, cross-
+build the exact current CLI source with the pinned builder, run this lifecycle
+against the product-managed Podman machine, and copy marked evidence back:
+
+```sh
+export OMNIDECK_VM_LAB_DIR=/mnt/data/VMs/omnideck-release-lab
+make macos-lab-test
+```
+
+The macOS lab target is disposable at the application layer. The controller
+resets it, installs the cross-built CLI into `~/.omnideck-lab/bin`, runs the
+ARM64 container lifecycle, collects evidence, and removes only the lab-managed
+CLI and namespaced state/resources as the lease exits. The user's normal CLI,
+config, and resources are preserved. Podman remains installed and warm;
+compilation and fixture-image construction stay on the Linux controller.
+
+This lane uses unique container, registry, volume, profile, and port names. It
+does not reset the physical Mac or claim a clean-host installation test.
+
Windows PowerShell:
```powershell
diff --git a/tests/hardware/fixture/Containerfile b/tests/hardware/fixture/Containerfile
index a90a3e1..64a5271 100644
--- a/tests/hardware/fixture/Containerfile
+++ b/tests/hardware/fixture/Containerfile
@@ -1,6 +1,5 @@
FROM docker.io/library/busybox:1.37.0@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0
-RUN mkdir -p /www /home/omnideck /var/lib/omnideck
COPY index.html /www/index.html
EXPOSE 8080
diff --git a/tests/hardware/macos-lab-install.sh b/tests/hardware/macos-lab-install.sh
new file mode 100755
index 0000000..c7639d3
--- /dev/null
+++ b/tests/hardware/macos-lab-install.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+export LANG=C
+export LC_ALL=C
+
+source_binary="${1:?Staged CLI path is required}"
+evidence_file="${2:-}"
+[[ "$(uname -s)/$(uname -m)" == Darwin/arm64 ]] || {
+ printf 'The macOS lab installer requires native Apple Silicon.\n' >&2
+ exit 2
+}
+[[ -x "$source_binary" ]] || { printf 'CLI candidate is not executable: %s\n' "$source_binary" >&2; exit 1; }
+file "$source_binary" | grep -Eq 'Mach-O 64-bit.*arm64'
+
+managed_root="$HOME/.omnideck-lab"
+destination="$managed_root/bin/omnideck"
+marker="$managed_root/managed-cli.sha256"
+[[ ! -e "$destination" && ! -L "$destination" ]] || {
+ printf 'The disposable baseline was not clean; CLI already exists: %s\n' "$destination" >&2
+ exit 1
+}
+mkdir -p "$(dirname "$destination")" "$managed_root"
+install -m 0755 "$source_binary" "$destination"
+shasum -a 256 "$destination" > "$marker"
+shasum -a 256 --check "$marker" >/dev/null
+if [[ -n "$evidence_file" ]]; then
+ mkdir -p "$(dirname "$evidence_file")"
+ digest="$(shasum -a 256 "$destination" | awk '{print $1}')"
+ version="$($destination --version)"
+ node - "$evidence_file" "$destination" "$digest" "$version" <<'NODE'
+const fs = require('node:fs');
+const [path, destination, sha256, version] = process.argv.slice(2);
+fs.writeFileSync(path, `${JSON.stringify({
+ schemaVersion: 1,
+ kind: 'cli',
+ destination,
+ sha256,
+ version,
+ architecture: 'arm64',
+}, null, 2)}\n`);
+NODE
+fi
+printf 'Installed CLI candidate: %s\n' "$destination"
diff --git a/tests/hardware/run-macos-lab.sh b/tests/hardware/run-macos-lab.sh
new file mode 100755
index 0000000..e9ec495
--- /dev/null
+++ b/tests/hardware/run-macos-lab.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+lab_dir="${OMNIDECK_VM_LAB_DIR:-}"
+target=macos-arm64
+profile="${OMNIDECK_VM_LAB_PROFILE:-release-clean}"
+builder_image="${OMNIDECK_CLI_BUILDER_IMAGE:-omnideck-cli-builder:local}"
+
+[[ -n "$lab_dir" && -x "$lab_dir/lab.sh" ]] || {
+ printf 'Set OMNIDECK_VM_LAB_DIR to the deployed OmniDeck VM lab.\n' >&2
+ exit 2
+}
+
+python3 -c 'import json,subprocess,sys; data=json.loads(subprocess.check_output([sys.argv[1], "capabilities", "--json"])); assert "remote-hosts" in data["features"]' "$lab_dir/lab.sh" || {
+ printf 'The deployed lab controller does not support physical hosts.\n' >&2
+ exit 2
+}
+
+if [[ "${OMNIDECK_VM_LAB_LEASED:-}" != 1 ]]; then
+ "$lab_dir/lab.sh" preflight cli "$profile" --lanes "$target" >/dev/null
+ command -v docker >/dev/null 2>&1 || { printf 'Docker is required to run the pinned Go builder.\n' >&2; exit 2; }
+ docker image inspect "$builder_image" >/dev/null 2>&1 || {
+ printf 'Builder image %q is unavailable. Build .devcontainer/Dockerfile first.\n' "$builder_image" >&2
+ exit 2
+ }
+
+ fixture_key="$(shasum -a 256 "$script_dir/fixture/Containerfile" "$script_dir/fixture/index.html" | shasum -a 256 | awk '{print substr($1,1,20)}')"
+ fixture_cache="$($lab_dir/lab.sh cache-path cli "macos-arm64-fixture-${fixture_key}")"
+ fixture_archive="$fixture_cache/fixture.tar"
+ fixture_image="localhost/omnideck-hardware-fixture:macos-arm64-${fixture_key}"
+ if [[ ! -f "$fixture_archive" ]]; then
+ mkdir -p "$fixture_cache"
+ temporary_archive="$fixture_cache/.fixture.tar.$$"
+ docker buildx build --platform linux/arm64 \
+ --file "$script_dir/fixture/Containerfile" --tag "$fixture_image" \
+ --output "type=docker,dest=${temporary_archive}" "$script_dir/fixture"
+ mv -- "$temporary_archive" "$fixture_archive"
+ shasum -a 256 "$fixture_archive" > "$fixture_cache/SHA256SUMS"
+ fi
+
+ run_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
+ source_commit="$(git -C "$repo_root" rev-parse HEAD)"
+ source_dirty=false
+ [[ -z "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=normal)" ]] || source_dirty=true
+ output_dir="$($lab_dir/lab.sh artifact-path cli macos-hardware "$run_id")"
+ build_dir="$output_dir/build"
+ mkdir -p "$build_dir"
+ binary="$build_dir/omnideck"
+ short_commit="${source_commit:0:12}"
+ ldflags="-X main.version=local-macos-arm64 -X main.commit=${short_commit}"
+
+ docker run --rm --entrypoint /bin/zsh \
+ --user "$(id -u):$(id -g)" \
+ --env GOOS=darwin --env GOARCH=arm64 --env CGO_ENABLED=0 \
+ --env GOCACHE=/tmp/omnideck-go-build --env GOPATH=/tmp/omnideck-go \
+ --volume "$repo_root:/workspace:ro" --volume "$build_dir:/out" \
+ --workdir /workspace "$builder_image" \
+ -c "go build -trimpath -buildvcs=false -ldflags \"${ldflags}\" -o /out/omnideck ."
+ chmod +x "$binary"
+ file "$binary" | grep -Eq 'Mach-O 64-bit.*arm64'
+ shasum -a 256 "$binary" > "$output_dir/SHA256SUMS"
+
+ "$lab_dir/lab.sh" evidence-init "$output_dir" cli macos-hardware "$run_id" \
+ "$source_commit" "$target" runtime-ready "sourceDirty=$source_dirty" "architecture=arm64" "artifactKind=source-build"
+ trap '"$lab_dir/lab.sh" evidence-finish "$output_dir" failed >/dev/null 2>&1 || true' EXIT
+ status=0
+ "$lab_dir/lab.sh" lease "$target" cli "$run_id" --cleanup-baseline runtime-ready -- env \
+ OMNIDECK_MACOS_CLI_BINARY="$binary" \
+ OMNIDECK_MACOS_FIXTURE_ARCHIVE="$fixture_archive" \
+ OMNIDECK_MACOS_FIXTURE_IMAGE="$fixture_image" \
+ OMNIDECK_VM_LAB_OUTPUT_DIR="$output_dir" \
+ "$0" || status=$?
+ trap - EXIT
+ if [[ "$status" == 0 ]]; then
+ "$lab_dir/lab.sh" evidence-finish "$output_dir" passed
+ else
+ "$lab_dir/lab.sh" evidence-finish "$output_dir" failed
+ fi
+ printf 'Evidence: %s\n' "$output_dir"
+ exit "$status"
+fi
+
+[[ "${OMNIDECK_VM_LAB_VM:-}" == "$target" ]] || {
+ printf 'The active lab lease does not own %s.\n' "$target" >&2
+ exit 2
+}
+binary="${OMNIDECK_MACOS_CLI_BINARY:?Prepared macOS CLI binary is required}"
+output_dir="${OMNIDECK_VM_LAB_OUTPUT_DIR:?Lab evidence directory is required}"
+fixture_archive="${OMNIDECK_MACOS_FIXTURE_ARCHIVE:?Prepared ARM64 fixture archive is required}"
+fixture_image="${OMNIDECK_MACOS_FIXTURE_IMAGE:?Prepared ARM64 fixture image name is required}"
+safe_run_id="$(printf '%s' "${OMNIDECK_VM_LAB_RUN_ID}" | tr -cd '[:alnum:]_.-')"
+remote_root="/tmp/omnideck-cli-macos-${safe_run_id}"
+remote_staged=0
+
+cleanup_remote() {
+ local status=$?
+ if [[ "$remote_staged" == 1 ]]; then
+ case "$remote_root" in
+ /tmp/omnideck-cli-macos-[[:alnum:]_.-]*) "$lab_dir/lab.sh" run "$target" rm -rf -- "$remote_root" >/dev/null 2>&1 || true ;;
+ *) printf 'Refusing to remove unexpected remote path: %s\n' "$remote_root" >&2; status=1 ;;
+ esac
+ fi
+ exit "$status"
+}
+trap cleanup_remote EXIT
+
+"$lab_dir/lab.sh" reset "$target" runtime-ready
+"$lab_dir/lab.sh" verify "$target"
+remote_home="$("$lab_dir/lab.sh" run "$target" /bin/zsh -c 'printf %s "$HOME"')"
+case "$remote_home" in /Users/*) ;; *) printf 'Unexpected remote home: %s\n' "$remote_home" >&2; exit 1 ;; esac
+"$lab_dir/lab.sh" run "$target" mkdir -p "$remote_root"
+remote_staged=1
+"$lab_dir/lab.sh" copy-to "$target" "$binary" "$remote_root/omnideck"
+"$lab_dir/lab.sh" copy-to "$target" "$fixture_archive" "$remote_root/fixture.tar"
+"$lab_dir/lab.sh" copy-to "$target" "$script_dir" "$remote_root/hardware"
+"$lab_dir/lab.sh" run "$target" chmod +x "$remote_root/omnideck" \
+ "$remote_root/hardware/run.sh" "$remote_root/hardware/macos-lab-install.sh"
+"$lab_dir/lab.sh" run "$target" "$remote_root/hardware/macos-lab-install.sh" \
+ "$remote_root/omnideck" "$remote_root/artifacts/install.json"
+
+test_status=0
+"$lab_dir/lab.sh" run "$target" env \
+ OMNIDECK_HARDWARE_CLI="$remote_home/.omnideck-lab/bin/omnideck" \
+ OMNIDECK_HARDWARE_ENGINE=podman \
+ OMNIDECK_HARDWARE_REGISTRY_PORT=46864 \
+ OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE="$remote_root/fixture.tar" \
+ OMNIDECK_HARDWARE_ARCHIVE_IMAGE="$fixture_image" \
+ OMNIDECK_HARDWARE_OUTPUT_DIR="$remote_root/artifacts" \
+ "$remote_root/hardware/run.sh" || test_status=$?
+
+mkdir -p "$output_dir/hardware"
+if ! "$lab_dir/lab.sh" copy-from "$target" "$remote_root/artifacts/." "$output_dir/hardware/"; then
+ [[ "$test_status" != 0 ]] || test_status=1
+fi
+if ! python3 - "$output_dir/hardware/install.json" "$binary" <<'PY'
+import hashlib, json, sys
+with open(sys.argv[1]) as handle:
+ installation = json.load(handle)
+with open(sys.argv[2], "rb") as handle:
+ expected = hashlib.sha256(handle.read()).hexdigest()
+assert installation["kind"] == "cli"
+assert installation["destination"].endswith("/.omnideck-lab/bin/omnideck")
+assert installation["architecture"] == "arm64"
+assert installation["sha256"] == expected
+PY
+then
+ [[ "$test_status" != 0 ]] || test_status=1
+fi
+exit "$test_status"
diff --git a/tests/hardware/run.sh b/tests/hardware/run.sh
index f428d2e..463125a 100755
--- a/tests/hardware/run.sh
+++ b/tests/hardware/run.sh
@@ -24,6 +24,8 @@ junit_file="${output_dir}/junit.xml"
config_path=""
engine=""
fixture_image="${OMNIDECK_HARDWARE_TEST_IMAGE:-}"
+fixture_archive="${OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE:-}"
+fixture_archive_image="${OMNIDECK_HARDWARE_ARCHIVE_IMAGE:-}"
local_fixture_image=""
registry_container="${instance}-registry"
built_fixture=0
@@ -150,6 +152,11 @@ fi
if [[ -z "${provided_cli}" ]]; then
command -v go >/dev/null 2>&1 || fail "Go is required to build the CLI. Set OMNIDECK_HARDWARE_CLI to test a prebuilt binary instead."
fi
+if [[ -n "${fixture_archive}" ]]; then
+ [[ -f "${fixture_archive}" ]] || fail "OMNIDECK_HARDWARE_TEST_IMAGE_ARCHIVE does not exist."
+ [[ "${fixture_archive_image}" =~ ^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$ ]] || fail "OMNIDECK_HARDWARE_ARCHIVE_IMAGE is required and must be a safe image reference."
+ [[ -z "${fixture_image}" ]] || fail "Use either a fixture image or an image archive, not both."
+fi
command -v curl >/dev/null 2>&1 || fail "curl is required to verify the fixture web UI."
select_engine
@@ -191,12 +198,21 @@ current_step="build fixture image"
if [[ -z "${fixture_image}" ]]; then
local_fixture_image="localhost/omnideck-hardware-fixture:${safe_run_id}"
fixture_image="localhost:${registry_port}/omnideck-hardware-fixture:${safe_run_id}"
- "${engine}" build --file "${script_dir}/fixture/Containerfile" --tag "${local_fixture_image}" "${script_dir}/fixture"
+ if [[ -n "${fixture_archive}" ]]; then
+ "${engine}" load --input "${fixture_archive}"
+ "${engine}" tag "${fixture_archive_image}" "${local_fixture_image}"
+ else
+ "${engine}" build --file "${script_dir}/fixture/Containerfile" --tag "${local_fixture_image}" "${script_dir}/fixture"
+ fi
built_fixture=1
"${engine}" run -d --name "${registry_container}" -p "127.0.0.1:${registry_port}:5000" docker.io/library/registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373
wait_for_registry
"${engine}" tag "${local_fixture_image}" "${fixture_image}"
- "${engine}" push "${fixture_image}"
+ if [[ "${engine}" == podman ]]; then
+ "${engine}" push --tls-verify=false "${fixture_image}"
+ else
+ "${engine}" push "${fixture_image}"
+ fi
fi
current_step="setup"
diff --git a/tests/manual/local-vm-lab.md b/tests/manual/local-vm-lab.md
index fa7d6f0..c1df495 100644
--- a/tests/manual/local-vm-lab.md
+++ b/tests/manual/local-vm-lab.md
@@ -4,6 +4,22 @@ This is the CLI workflow for the external OmniDeck release lab. Prefer the
automated E2E matrix for every deterministic Linux and Windows check. Open a
graphical viewer only for behavior listed as manual in the applicable procedure.
+The lab's leased physical Apple Silicon host has a separate automated source
+lane:
+
+```sh
+export OMNIDECK_VM_LAB_DIR=/mnt/data/VMs/omnideck-release-lab
+make macos-lab-test
+```
+
+This lane installs the prepared CLI under `~/.omnideck-lab` on the dedicated
+Mac and owns a `runtime-ready` cleanup baseline. It removes the lab-managed
+binary and namespaced resources after every run while retaining the user's
+normal CLI/config/resources and the costly Podman installation and machine.
+
+It runs isolated CLI/container lifecycle coverage and copies evidence back to
+the lab without treating the physical Mac as a resettable VM.
+
## Preflight and ownership
Set the lab path without committing its machine-specific value: