mirror of
https://github.com/logos-co/eth-lez-atomic-swaps.git
synced 2026-08-27 09:51:11 +00:00
test(basecamp): exercise swap UI inside real Basecamp (#90)
Squash-merged locally over SSH: gh pr merge was blocked because the PR
touches .github/workflows/ci.yml and the CLI token lacks the workflow
scope. Content is identical to PR #90 head 7a7481e.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63911dba64
commit
f69b5a0739
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build and exercise swap_ui as an installed module inside the real Basecamp
|
||||
# bundle pinned by scaffold.toml. Basecamp's inspector build differs from the
|
||||
# shipping bundle only by enabling logos-qt-mcp; package loading, ui-host,
|
||||
# logos_host, and the application shell are the production code paths.
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)
|
||||
cd "$repo_root"
|
||||
|
||||
artifact_dir=${BASECAMP_UI_ARTIFACT_DIR:-"$repo_root/artifacts/basecamp-ui-smoke"}
|
||||
mkdir -p "$artifact_dir"
|
||||
artifact_dir=$(cd "$artifact_dir" && pwd -P)
|
||||
exec > >(tee "$artifact_dir/harness.log") 2>&1
|
||||
|
||||
for command_name in nix node awk find; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || {
|
||||
echo "missing required command: $command_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
toml_value() {
|
||||
local section=$1
|
||||
local key=$2
|
||||
awk -v wanted_section="[$section]" -v wanted_key="$key" '
|
||||
/^\[/ { in_section = ($0 == wanted_section) }
|
||||
in_section && $0 ~ "^[[:space:]]*" wanted_key "[[:space:]]*=" {
|
||||
value = $0
|
||||
sub(/^[^=]*=[[:space:]]*/, "", value)
|
||||
if (match(value, /^"[^"]*"/)) {
|
||||
value = substr(value, RSTART + 1, RLENGTH - 2)
|
||||
} else {
|
||||
sub(/[[:space:]]*(#.*)?$/, "", value)
|
||||
}
|
||||
print value
|
||||
exit
|
||||
}
|
||||
' scaffold.toml
|
||||
}
|
||||
|
||||
basecamp_source=$(toml_value repos.basecamp source)
|
||||
basecamp_pin=$(toml_value repos.basecamp pin)
|
||||
lgpm_source=$(toml_value repos.lgpm source)
|
||||
lgpm_pin=$(toml_value repos.lgpm pin)
|
||||
delivery_flake=$(toml_value modules.delivery_module flake)
|
||||
|
||||
if [[ "$basecamp_source" != "https://github.com/logos-co/logos-basecamp.git" ]]; then
|
||||
echo "unsupported [repos.basecamp].source: $basecamp_source" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$lgpm_source" != "github:logos-co/logos-package-manager" ]]; then
|
||||
echo "unsupported [repos.lgpm].source: $lgpm_source" >&2
|
||||
exit 1
|
||||
fi
|
||||
for pin_name in basecamp_pin lgpm_pin; do
|
||||
pin_value=${!pin_name}
|
||||
if [[ ! "$pin_value" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "$pin_name must be a full 40-character Git commit, got: $pin_value" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ -z "$delivery_flake" || "$delivery_flake" != *"#lgx" ]]; then
|
||||
echo "[modules.delivery_module].flake must end in #lgx, got: $delivery_flake" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
basecamp_ref="github:logos-co/logos-basecamp/$basecamp_pin"
|
||||
lgpm_ref="$lgpm_source/$lgpm_pin"
|
||||
delivery_ref="${delivery_flake%#lgx}#lgx-portable"
|
||||
|
||||
build_one() {
|
||||
local label=$1
|
||||
local ref=$2
|
||||
shift 2
|
||||
local output
|
||||
echo "==> Building $label: $ref" >&2
|
||||
output=$(nix build "$ref" --no-link --print-out-paths "$@")
|
||||
if [[ $(printf '%s\n' "$output" | sed '/^$/d' | wc -l | tr -d ' ') != "1" ]]; then
|
||||
echo "$label produced an unexpected output list:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
find_single_lgx() {
|
||||
local label=$1
|
||||
local output_dir=$2
|
||||
local matches=()
|
||||
while IFS= read -r candidate; do
|
||||
matches+=("$candidate")
|
||||
done < <(find "$output_dir" -maxdepth 1 -type f -name '*.lgx' -print)
|
||||
if [[ ${#matches[@]} -ne 1 ]]; then
|
||||
echo "$label output must contain exactly one .lgx, found ${#matches[@]} in $output_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "${matches[0]}"
|
||||
}
|
||||
|
||||
# Build the same portable package variants that Basecamp consumes. The module
|
||||
# flakes remain package-only; the host and test framework come from Basecamp.
|
||||
# Keep the two scoped nixpkgs workarounds identical to the PR-time module
|
||||
# matrix: the pinned builder and Delivery nixpkgs revisions predate
|
||||
# crates.io's User-Agent fix. They change only the affected nested input, not
|
||||
# this repo's flake outputs or host selection.
|
||||
module_builder_nixpkgs='github:danisharora099/nixpkgs/eaec81d3b8a8d2339e25c718a1650b2c45adf726'
|
||||
delivery_nixpkgs='github:danisharora099/nixpkgs/3134d7bb12629545b1f3e5b1d2faadbf861484fd'
|
||||
delivery_overrides=(
|
||||
--override-input logos-delivery/nixpkgs "$delivery_nixpkgs"
|
||||
)
|
||||
swap_overrides=(
|
||||
--override-input logos-module-builder/nixpkgs "$module_builder_nixpkgs"
|
||||
--override-input delivery_module/logos-delivery/nixpkgs "$delivery_nixpkgs"
|
||||
)
|
||||
swap_ui_overrides=(
|
||||
--override-input logos-module-builder/nixpkgs "$module_builder_nixpkgs"
|
||||
--override-input swap/logos-module-builder/nixpkgs "$module_builder_nixpkgs"
|
||||
--override-input swap/delivery_module/logos-delivery/nixpkgs "$delivery_nixpkgs"
|
||||
)
|
||||
delivery_output=$(build_one delivery_module "$delivery_ref" "${delivery_overrides[@]}")
|
||||
swap_output=$(build_one swap 'git+file:.?dir=swap-module#lgx-portable' "${swap_overrides[@]}")
|
||||
swap_ui_output=$(build_one swap_ui 'git+file:.?dir=swap-ui#lgx-portable' "${swap_ui_overrides[@]}")
|
||||
lgpm_output=$(build_one lgpm "$lgpm_ref#cli-portable")
|
||||
basecamp_output=$(build_one 'Basecamp inspector bundle' "$basecamp_ref#bin-bundle-dir-inspector")
|
||||
qt_mcp_output=$(build_one logos-qt-mcp "$basecamp_ref#logos-qt-mcp")
|
||||
|
||||
delivery_lgx=$(find_single_lgx delivery_module "$delivery_output")
|
||||
swap_lgx=$(find_single_lgx swap "$swap_output")
|
||||
swap_ui_lgx=$(find_single_lgx swap_ui "$swap_ui_output")
|
||||
lgpm_bin="$lgpm_output/bin/lgpm"
|
||||
basecamp_bin="$basecamp_output/bin/LogosBasecamp"
|
||||
|
||||
[[ -x "$lgpm_bin" ]] || { echo "lgpm binary missing: $lgpm_bin" >&2; exit 1; }
|
||||
[[ -x "$basecamp_bin" ]] || { echo "Basecamp binary missing: $basecamp_bin" >&2; exit 1; }
|
||||
[[ -f "$qt_mcp_output/test-framework/framework.mjs" ]] || {
|
||||
echo "logos-qt-mcp test framework missing from $qt_mcp_output" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Nix's Linux bundle deliberately leaves the host graphics-driver ABI outside
|
||||
# its closure. Enumerate every unresolved ELF dependency in one failure instead
|
||||
# of discovering one library per expensive cold CI build.
|
||||
if [[ $(uname -s) == Linux ]]; then
|
||||
basecamp_elf="$basecamp_output/bin/.LogosBasecamp.elf"
|
||||
[[ -x "$basecamp_elf" ]] || { echo "Basecamp ELF missing: $basecamp_elf" >&2; exit 1; }
|
||||
if ! ldd_output=$(ldd "$basecamp_elf" 2>&1); then
|
||||
echo "ldd could not inspect the Basecamp ELF:" >&2
|
||||
printf '%s\n' "$ldd_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
missing_libraries=$(printf '%s\n' "$ldd_output" | awk '$2 == "=>" && $3 == "not" && !seen[$1]++ { print $1 }')
|
||||
if [[ -n "$missing_libraries" ]]; then
|
||||
echo "Basecamp ELF has unresolved system libraries:" >&2
|
||||
printf '%s\n' "$missing_libraries" | awk '{ print " " $0 }' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
test_root=$(mktemp -d /tmp/atomic-swaps-basecamp-ui.XXXXXX)
|
||||
user_dir="$test_root/user-dir"
|
||||
runtime_dir="$test_root/runtime"
|
||||
mkdir -p "$user_dir/modules" "$user_dir/plugins" "$runtime_dir"
|
||||
chmod 700 "$runtime_dir"
|
||||
[[ $(stat -c '%a' "$runtime_dir" 2>/dev/null || stat -f '%Lp' "$runtime_dir") == 700 ]] || {
|
||||
echo "XDG runtime directory is not owner-only: $runtime_dir" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT
|
||||
if [[ -d "$user_dir/logs" ]]; then
|
||||
mkdir -p "$artifact_dir/basecamp-logs"
|
||||
cp -R "$user_dir/logs/." "$artifact_dir/basecamp-logs/" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -d "$runtime_dir" ]]; then
|
||||
mkdir -p "$artifact_dir/runtime-logs"
|
||||
cp -R "$runtime_dir/." "$artifact_dir/runtime-logs/" 2>/dev/null || true
|
||||
fi
|
||||
if [[ ${BASECAMP_UI_KEEP_TMP:-0} == 1 ]]; then
|
||||
echo "Preserving test root: $test_root"
|
||||
elif [[ "$test_root" == /tmp/atomic-swaps-basecamp-ui.* && -d "$test_root" ]]; then
|
||||
rm -rf -- "$test_root"
|
||||
else
|
||||
echo "refusing to remove unexpected test root: $test_root" >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Installing portable packages into $user_dir"
|
||||
for lgx in "$delivery_lgx" "$swap_lgx" "$swap_ui_lgx"; do
|
||||
"$lgpm_bin" \
|
||||
--modules-dir "$user_dir/modules" \
|
||||
--ui-plugins-dir "$user_dir/plugins" \
|
||||
install --file "$lgx"
|
||||
done
|
||||
|
||||
# Fail before launch if LGPM put an artifact in the wrong package class or
|
||||
# stripped the dependency/view metadata Basecamp needs to discover it.
|
||||
test -f "$user_dir/modules/delivery_module/manifest.json"
|
||||
test -f "$user_dir/modules/swap/manifest.json"
|
||||
test -f "$user_dir/plugins/swap_ui/manifest.json"
|
||||
node - "$user_dir" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const root = process.argv[2];
|
||||
const read = (kind, name) => JSON.parse(
|
||||
fs.readFileSync(path.join(root, kind, name, "manifest.json"), "utf8")
|
||||
);
|
||||
const delivery = read("modules", "delivery_module");
|
||||
const swap = read("modules", "swap");
|
||||
const ui = read("plugins", "swap_ui");
|
||||
if (delivery.name !== "delivery_module" || delivery.type !== "core")
|
||||
throw new Error("delivery_module manifest is not an installed core module");
|
||||
if (swap.name !== "swap" || swap.type !== "core" || !(swap.dependencies || []).includes("delivery_module"))
|
||||
throw new Error("swap manifest lost its delivery_module dependency");
|
||||
if (ui.name !== "swap_ui" || ui.type !== "ui_qml" || !(ui.dependencies || []).includes("swap") || !ui.view)
|
||||
throw new Error("swap_ui manifest is not a Basecamp ui_qml plugin with its swap dependency and view");
|
||||
NODE
|
||||
|
||||
echo "==> Launching the real pinned Basecamp bundle and running UI assertions"
|
||||
BASECAMP_BIN="$basecamp_bin" \
|
||||
BASECAMP_USER_DIR="$user_dir" \
|
||||
BASECAMP_RUNTIME_DIR="$runtime_dir" \
|
||||
BASECAMP_UI_ARTIFACT_DIR="$artifact_dir" \
|
||||
LOGOS_QT_MCP="$qt_mcp_output" \
|
||||
node tests/basecamp-ui-smoke.mjs
|
||||
+35
-55
@@ -1,19 +1,17 @@
|
||||
name: CI
|
||||
|
||||
# Minimal PR-time artifact builds. Two jobs:
|
||||
# PR-time compile and runtime coverage. Two jobs:
|
||||
#
|
||||
# rust-checks — proves the Rust workspace (incl. the swap-ffi cdylib the
|
||||
# Basecamp module wraps) still compiles and its unit tests
|
||||
# pass, without the risc0 guest toolchain.
|
||||
# module-build — proves the swap_ui Basecamp module (and transitively the
|
||||
# swap backend module + the swap-ffi nix build with its
|
||||
# pinned cargoHash) still builds via nix, exactly like the
|
||||
# release pipeline does.
|
||||
# basecamp-ui-runtime — installs the portable delivery/swap/swap_ui packages
|
||||
# into an isolated profile and drives them inside the
|
||||
# real pinned Basecamp bundle, headlessly.
|
||||
#
|
||||
# Deliberately out of scope for v1: runtime QML load (nix/qmlcachegen does
|
||||
# not catch QML runtime defects — loading the module in a real Basecamp
|
||||
# session is still a manual review gate), the risc0 guest build (`demo`
|
||||
# feature), localnet integration tests, and releases.
|
||||
# Deliberately out of scope: a funded two-peer swap (covered by the manual
|
||||
# Basecamp run and headless demo), macOS accessibility semantics, the risc0
|
||||
# guest build (`demo` feature), localnet integration tests, and releases.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -97,58 +95,40 @@ jobs:
|
||||
- name: cargo test (anvil integration)
|
||||
run: cargo test --locked --test taker_binding --test eth_integration
|
||||
|
||||
module-build:
|
||||
name: module-build (nix build swap-ui#lgx-portable)
|
||||
# aarch64-darwin here specifically to keep this PR-time job cheap and
|
||||
# fast; it is no longer the only buildable platform. Issue #32 (PR #53)
|
||||
# pinned real circuits/rapidsnark hashes for linux-amd64 and linux-arm64
|
||||
# too, and .github/workflows/build-modules.yml now compiles both
|
||||
# modules on all three variants (incl. two Linux runners) on every
|
||||
# push/PR — that job is the one that actually proves Linux builds.
|
||||
# This job stays darwin-only as a fast single-variant portable-package
|
||||
# smoke check; the full matrix owns cross-platform coverage.
|
||||
runs-on: macos-latest
|
||||
basecamp-ui-runtime:
|
||||
name: basecamp-ui-runtime (real Basecamp, linux-amd64)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# git+file:. flake refs want a full, non-shallow clone.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
with:
|
||||
extra-conf: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
# No nix store caching yet: magic-nix-cache was sunset with the GitHub
|
||||
# Actions Cache v1 API, so every run builds cold (binary-cache
|
||||
# substitution from cache.nixos.org still covers nixpkgs deps).
|
||||
# Follow-up: adopt nix-community/cache-nix-action (or a cachix cache)
|
||||
# once validated on a real runner.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LOUD CAVEAT — the three --override-input flags below are a
|
||||
# temporary workaround, not part of the build definition:
|
||||
#
|
||||
# Cold cargo-vendor fetches 403 on crates.io because the nixpkgs
|
||||
# commits pinned by logos-module-builder (e9f00bd8) and logos-delivery
|
||||
# (23d72dab) ship a fetch-cargo-vendor-util without a User-Agent
|
||||
# (logos-module-builder#159; crates.io rejects the python-requests
|
||||
# default UA). The overrides point at forks carrying the upstream fix
|
||||
# (NixOS/nixpkgs@8209ba2b) applied ONLY to the vendor-staging FOD, so
|
||||
# no input-addressed store paths change.
|
||||
#
|
||||
# * DROP the two eaec81d3 overrides when logos-module-builder#173
|
||||
# (repins its nixpkgs) merges AND this repo's module flakes pick
|
||||
# up the new builder rev.
|
||||
# * DROP the 3134d7bb override when logos-delivery-module repins its
|
||||
# own nixpkgs the same way (sibling fix, not covered by #173).
|
||||
#
|
||||
# Known flake: even with the UA fix, a cold vendor fetch can hit a
|
||||
# transient crates.io 429 (rate limit — the fetcher downloads crates
|
||||
# in parallel with no backoff). If this step fails with "Status code:
|
||||
# 429", re-run the job.
|
||||
# ------------------------------------------------------------------
|
||||
- name: nix build swap_ui Basecamp package (lgx-portable)
|
||||
# The portable Basecamp bundle carries its Qt stack, but Qt's Linux
|
||||
# platform support still loads the system OpenGL ABI at process start.
|
||||
# ubuntu-latest does not include libOpenGL.so.0 by default.
|
||||
- name: Install Basecamp system runtime libraries
|
||||
run: |
|
||||
nix build 'git+file:.?dir=swap-ui#lgx-portable' --no-link --print-out-paths \
|
||||
--override-input logos-module-builder/nixpkgs github:danisharora099/nixpkgs/eaec81d3b8a8d2339e25c718a1650b2c45adf726 \
|
||||
--override-input swap/logos-module-builder/nixpkgs github:danisharora099/nixpkgs/eaec81d3b8a8d2339e25c718a1650b2c45adf726 \
|
||||
--override-input swap/delivery_module/logos-delivery/nixpkgs github:danisharora099/nixpkgs/3134d7bb12629545b1f3e5b1d2faadbf861484fd
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes libegl1 libgl1 libglx0 libopengl0
|
||||
|
||||
- name: Install modules and run Basecamp-native UI smoke test
|
||||
run: make basecamp-ui-smoke
|
||||
|
||||
- name: Upload Basecamp failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: basecamp-ui-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/basecamp-ui-smoke/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -70,3 +70,6 @@ result
|
||||
result-*
|
||||
result-basecamp
|
||||
.scaffold
|
||||
|
||||
# Basecamp-native UI smoke-test diagnostics
|
||||
/artifacts/basecamp-ui-smoke/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.PHONY: contracts demo infra \
|
||||
setup localnet-start localnet-stop test
|
||||
setup localnet-start localnet-stop test basecamp-ui-smoke
|
||||
|
||||
.DEFAULT_GOAL := contracts
|
||||
|
||||
@@ -102,6 +102,13 @@ infra: contracts localnet-start
|
||||
# launch` inherits ambient env, which is what lets this bridge work at all.
|
||||
BASECAMP_USER_DIR = $(CURDIR)/.scaffold/basecamp/profiles/$*/xdg-data/Logos/LogosBasecamp
|
||||
|
||||
# Hermetic UI/runtime smoke test. This builds the portable LGX packages,
|
||||
# installs them into a throwaway user-dir, and drives the real pinned Basecamp
|
||||
# bundle with its test-only QML inspector enabled. It does not start localnet or
|
||||
# Anvil and never uses a module-owned app host.
|
||||
basecamp-ui-smoke:
|
||||
bash .github/scripts/run-basecamp-ui-smoke.sh
|
||||
|
||||
# The grep guard turns a scaffold-side layout change into a hard error instead
|
||||
# of a silently-wrong-directory launch (which looks like "the app opened but my
|
||||
# module vanished"). It reuses scaffold's own resolved manifest, so the two
|
||||
|
||||
@@ -72,7 +72,7 @@ What each phase does:
|
||||
|---|---|
|
||||
| `make setup` | Runs `lgs setup` through the [v0.2.0 bridge](scripts/scaffold-setup.sh): fetches `logos-blockchain-circuits` into `.scaffold/lez-cache/circuits` (driven by the `[circuits]` block in [`scaffold.toml`](scaffold.toml)), creates the local LEZ checkout and wallet under `.scaffold/`, and seeds the default wallet address. |
|
||||
| `lgs basecamp build` | Runs the aggregate module build, producing the `swap`, `swap_ui`, and `delivery_module` LGX artifacts under `.scaffold/basecamp/{lgx,portable}/`. |
|
||||
| `lgs basecamp setup` | Builds the portable `bin-macos-app` Basecamp (`d41a72bc` / tag `0.2.2`) and the `cli-portable` LGPM (`202af6fa`, the rev that Basecamp pin builds against), then seeds the two Basecamp profiles. |
|
||||
| `lgs basecamp setup` | Builds the portable `bin-macos-app` Basecamp (`aa237766` / tag `0.2.3`) and the `cli-portable` LGPM (`202af6fa`, the rev that Basecamp pin builds against), then seeds the two Basecamp profiles. |
|
||||
| `lgs basecamp install` | Installs the three `#lgx-portable` packages (`delivery_module` + `swap` as modules, `swap_ui` as a plugin) via `lgpm cli-portable` into scaffold's default profiles as a stack check; the `maker` / `taker` profiles are provisioned the same way automatically on their first `lgs basecamp launch`. The portable Basecamp and `lgpm cli-portable` agree on the bare `darwin-arm64` variant, so the install completes with zero variant errors. |
|
||||
| `make infra` | Starts Anvil and the LEZ localnet, deploys the ETH HTLC contract, and writes `.env` / `.env.taker`. Keep this running. |
|
||||
| `make basecamp-launch-maker` / `make basecamp-launch-taker` | Launches the two Basecamp windows with the correct role, env file, and an absolute per-profile `LOGOS_USER_DIR` so each peer sees its own installed modules. |
|
||||
@@ -172,7 +172,7 @@ Read this before filing an issue — these are the current known rough spots for
|
||||
- **`lgs doctor` shows one expected warning** — a `delivery_module` pin-drift warn (v0.1.1 vs scaffold's default rev) keeps the status at "Needs attention"; it is benign.
|
||||
- **Launch through `make basecamp-launch-<profile>`, not bare `lgs basecamp launch`.** Basecamp 0.2.x reads `LOGOS_USER_DIR`, not the `LOGOS_DATA_DIR` scaffold sets. Unbridged, the app still opens — it just silently uses the shared `~/Library/Application Support/Logos/LogosBasecamp` and shows none of this project's modules. See [Manual Basecamp Run](#manual-basecamp-run) and tracker TR-21.
|
||||
- **A pre-0.2.x Basecamp may have left artifacts in the shared dir.** If you ran an older pin before, `~/Library/Application Support/Logos/LogosBasecamp/{modules,plugins}` can hold old-SDK dylibs. Basecamp 0.2.x logs `carries no usable logos_protocol_version (pre-protocol build) — loading permissively` and loads them anyway, which can destabilise the app. Move that directory aside if you hit odd crashes; the bridged per-profile launch does not touch it.
|
||||
- **The `lgpm` pin is paired with the basecamp pin.** `[repos.lgpm]` must stay on the `logos-package-manager` rev the pinned Basecamp builds against (currently `202af6fa` for `0.2.2` — found by reading the *root* `logos-package-manager` input in `logos-basecamp`'s own `flake.lock`, not one of its many indirect/transitive nodes, and corroborated by the 0.2.2-era commit ["fix: use the shared semver comparator for install-status" (#257)](https://github.com/logos-co/logos-basecamp/commit/fd633a51eff3c37e964bae6d65ceb3f43ed62e01) which states the pairing explicitly). On the older `e5c25989` pin, `lgpm install` silently dropped `view` from the installed `manifest.json` and Basecamp 0.2.x then filtered `swap_ui` out of the launcher entirely — it installed fine and never appeared. Bump both pins together, and after installing, check that the installed `swap_ui/manifest.json` has a non-empty `view` field (`lgpm list --json` reports the same data Basecamp's package manager module reads) — that field, not just a zero exit code, is what proves the pairing is correct.
|
||||
- **The `lgpm` pin is paired with the basecamp pin.** `[repos.lgpm]` must stay on the `logos-package-manager` rev the pinned Basecamp builds against (currently `202af6fa` for `0.2.3` — found by reading the *root* `logos-package-manager` input in `logos-basecamp`'s own `flake.lock`, not one of its many indirect/transitive nodes, and corroborated by the 0.2.2-era commit ["fix: use the shared semver comparator for install-status" (#257)](https://github.com/logos-co/logos-basecamp/commit/fd633a51eff3c37e964bae6d65ceb3f43ed62e01) which states the pairing explicitly). On the older `e5c25989` pin, `lgpm install` silently dropped `view` from the installed `manifest.json` and Basecamp 0.2.x then filtered `swap_ui` out of the launcher entirely — it installed fine and never appeared. Re-derive this pair for every Basecamp bump and update either pin that changed. After installing, check that the installed `swap_ui/manifest.json` has a non-empty `view` field (`lgpm list --json` reports the same data Basecamp's package manager module reads) — that field, not just a zero exit code, is what proves the pairing is correct.
|
||||
|
||||
Found something else? Share it with the [Atomic swap trial feedback form](https://github.com/logos-co/eth-lez-atomic-swaps/issues/new?template=trial-feedback.yml). Success reports are welcome, and the form asks only for Basecamp-visible versions and secret-free evidence.
|
||||
|
||||
@@ -256,6 +256,16 @@ make basecamp-launch-maker
|
||||
|
||||
`make basecamp-launch-maker` supplies the required isolated `LOGOS_USER_DIR`, then Scaffold scrubs and reprovisions that profile from the captured module set before starting Basecamp. Do not invoke bare `lgs basecamp launch` on this pin, and do not use `lgs basecamp run swap_ui`: the former silently falls back to Basecamp's shared data tree, while the latter uses Scaffold's unsupported standalone host.
|
||||
|
||||
For the automated, unfunded UI/runtime smoke test:
|
||||
|
||||
```bash
|
||||
make basecamp-ui-smoke
|
||||
```
|
||||
|
||||
This reads the Basecamp and LGPM commits from `scaffold.toml`, builds Basecamp's test-only `bin-bundle-dir-inspector` output, and installs the portable `delivery_module`, `swap`, and `swap_ui` LGX packages into a unique temporary `--user-dir`. The module builds reuse the same two scoped nixpkgs overrides as the PR-time package build, avoiding the pinned cargo fetcher's crates.io User-Agent failure without changing the module flakes or their exported outputs. The process launched is the real pinned `LogosBasecamp` binary; the test-only difference is that its upstream QML inspector is enabled so CI can drive it with `QT_QPA_PLATFORM=offscreen`. The test opens **ETH ↔ LEZ Atomic Swap**, verifies the `logos_host` / `ui-host` process topology and backend readiness, and visits all six tabs. It requires Nix and Node.js, but not Anvil, a LEZ localnet, wallet funding, or display access.
|
||||
|
||||
The runner creates a dedicated POSIX process group and asks only that Basecamp instance to shut down. Basecamp intentionally gives each `logos_host` / `ui-host` child its own process group; if graceful shutdown leaves one behind, the runner will signal an exact captured PID only after its complete command still matches and contains the run's unique temporary user-dir. It never uses global process-name matching, so it cannot stop a developer's normal Basecamp session. Diagnostics are written to `artifacts/basecamp-ui-smoke/`; CI uploads them on failure. This Linux smoke test catches package discovery, module dependency, process startup, QML load, and navigation regressions. macOS accessibility behavior remains a local review gate against the shipping `.app`, because offscreen QML inspection does not exercise Cocoa's accessibility bridge.
|
||||
|
||||
## Headless Demo And CLI Usage
|
||||
|
||||
For a quick automated end-to-end swap without the UI:
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ pin = "3d639076ec0946a6a6db799dd5a4648007f362ee"
|
||||
|
||||
[repos.basecamp]
|
||||
source = "https://github.com/logos-co/logos-basecamp.git"
|
||||
pin = "d41a72bc7d77cfe25dfe2b888a70f16832ec8c53"
|
||||
pin = "aa237766baf61404e12da86b7303cb41065464c9"
|
||||
build = "nix-flake"
|
||||
attr = { aarch64-darwin = "bin-macos-app", aarch64-linux = "bin-appimage", x86_64-darwin = "bin-macos-app", x86_64-linux = "bin-appimage" }
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { createWriteStream, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
const appBin = process.env.BASECAMP_BIN;
|
||||
const userDir = process.env.BASECAMP_USER_DIR;
|
||||
const runtimeDir = process.env.BASECAMP_RUNTIME_DIR;
|
||||
const artifactDir = resolve(process.env.BASECAMP_UI_ARTIFACT_DIR || "artifacts/basecamp-ui-smoke");
|
||||
const qtMcpRoot = process.env.LOGOS_QT_MCP;
|
||||
|
||||
for (const [name, value] of Object.entries({ appBin, userDir, runtimeDir, qtMcpRoot })) {
|
||||
if (!value) throw new Error(`missing required environment variable for ${name}`);
|
||||
}
|
||||
if (basename(appBin) !== "LogosBasecamp")
|
||||
throw new Error(`refusing to launch a non-Basecamp host: ${appBin}`);
|
||||
for (const [name, value] of Object.entries({ userDir, runtimeDir, artifactDir })) {
|
||||
if (!isAbsolute(value)) throw new Error(`${name} must be absolute: ${value}`);
|
||||
}
|
||||
if (!/^\/tmp\/atomic-swaps-basecamp-ui\.[A-Za-z0-9]+\/user-dir$/.test(userDir)) {
|
||||
throw new Error(`refusing to use a non-ephemeral Basecamp user-dir: ${userDir}`);
|
||||
}
|
||||
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
async function reserveInspectorPort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolveListen, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolveListen);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()));
|
||||
if (!port) throw new Error("failed to reserve a QML inspector port");
|
||||
return port;
|
||||
}
|
||||
|
||||
function processRows() {
|
||||
const output = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,command="], { encoding: "utf8" });
|
||||
return output.split("\n").flatMap((line) => {
|
||||
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
|
||||
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), command: match[4] }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function descendantsOf(rootPid) {
|
||||
const rows = processRows();
|
||||
const found = [];
|
||||
const queue = [rootPid];
|
||||
while (queue.length > 0) {
|
||||
const parent = queue.shift();
|
||||
for (const row of rows) {
|
||||
if (row.ppid === parent && !found.some((entry) => entry.pid === row.pid)) {
|
||||
found.push(row);
|
||||
queue.push(row.pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function formatProcessTree(rootPid) {
|
||||
const descendantPids = new Set(descendantsOf(rootPid).map((row) => row.pid));
|
||||
const rows = processRows().filter((row) => row.pid === rootPid || descendantPids.has(row.pid));
|
||||
return rows.map((row) => `${row.pid}\t${row.ppid}\t${row.pgid}\t${row.command}`).join("\n") + "\n";
|
||||
}
|
||||
|
||||
function moduleCommand(rows, executable, moduleName) {
|
||||
const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedExecutable = executable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Bundle wrappers use the public executable name, while Linux process
|
||||
// command lines expose the wrapped ELF basename (for example,
|
||||
// `.logos_host.elf`). Both are the same Basecamp-owned host path.
|
||||
const executableBasename = `(?:${escapedExecutable}|\\.${escapedExecutable}\\.elf)`;
|
||||
const expression = new RegExp(`(?:^|/)${executableBasename}(?:\\s|$).*--name(?:=|\\s+)${escaped}(?:\\s|$)`);
|
||||
return rows.find((row) => expression.test(row.command));
|
||||
}
|
||||
|
||||
class FatalWaitError extends Error {}
|
||||
|
||||
async function waitFor(description, callback, timeoutMs = 45000, intervalMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await callback();
|
||||
if (value) return value;
|
||||
} catch (error) {
|
||||
if (error instanceof FatalWaitError) throw error;
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, intervalMs));
|
||||
}
|
||||
throw new Error(`${description} did not become ready within ${timeoutMs}ms${lastError ? `: ${lastError.message}` : ""}`);
|
||||
}
|
||||
|
||||
function capturedSurvivors(captured) {
|
||||
const currentByPid = new Map(processRows().map((row) => [row.pid, row]));
|
||||
return captured.flatMap((original) => {
|
||||
const current = currentByPid.get(original.pid);
|
||||
return current ? [{ original, current }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function verifiedOwnedSurvivors(captured) {
|
||||
const survivors = capturedSurvivors(captured);
|
||||
const unsafe = survivors.filter(({ original, current }) =>
|
||||
current.command !== original.command || !current.command.includes(userDir)
|
||||
);
|
||||
if (unsafe.length > 0) {
|
||||
throw new Error(
|
||||
"refusing to signal a surviving PID whose command no longer matches the captured Basecamp test process:\n" +
|
||||
unsafe.map(({ original, current }) =>
|
||||
`${original.pid}\n captured: ${original.command}\n current: ${current.command}`
|
||||
).join("\n")
|
||||
);
|
||||
}
|
||||
return survivors.map(({ current }) => current);
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return true;
|
||||
return Promise.race([
|
||||
new Promise((resolveExit) => child.once("exit", () => resolveExit(true))),
|
||||
new Promise((resolveTimeout) => setTimeout(() => resolveTimeout(false), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function terminateOwnedProcesses(child) {
|
||||
if (!child?.pid) return;
|
||||
const pgid = child.pid; // spawn({ detached: true }) creates this new POSIX process group.
|
||||
const rows = processRows();
|
||||
const root = rows.find((row) => row.pid === child.pid);
|
||||
// The unique absolute mktemp user-dir is the durable ownership boundary.
|
||||
// Ancestry is useful while Basecamp is alive, but module hosts can be
|
||||
// reparented if the root crashes before teardown begins.
|
||||
const captured = rows.filter((row) => row.command.includes(userDir));
|
||||
if (captured.length === 0) return;
|
||||
|
||||
if (root?.command.includes(userDir)) {
|
||||
try { process.kill(-pgid, "SIGTERM"); } catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
await waitForChildExit(child, 8000);
|
||||
}
|
||||
await waitFor("Basecamp process tree to stop after SIGTERM", () => capturedSurvivors(captured).length === 0, 8000, 250)
|
||||
.catch(() => false);
|
||||
|
||||
// Basecamp intentionally starts each logos_host/ui-host in its own process
|
||||
// group. Normal SIGTERM shutdown reaps them. If one survives, signal only
|
||||
// the exact captured PID after verifying its full command is unchanged and
|
||||
// still contains this run's unique user-dir marker.
|
||||
let survivors = verifiedOwnedSurvivors(captured);
|
||||
for (const survivor of survivors) {
|
||||
try { process.kill(survivor.pid, "SIGTERM"); } catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
if (survivors.length > 0) {
|
||||
await waitFor("captured Basecamp children to stop", () => capturedSurvivors(captured).length === 0, 3000, 250)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
survivors = verifiedOwnedSurvivors(captured);
|
||||
for (const survivor of survivors) {
|
||||
try { process.kill(survivor.pid, "SIGKILL"); } catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
if (survivors.length > 0) {
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 500));
|
||||
}
|
||||
|
||||
survivors = capturedSurvivors(captured).map(({ current }) => current);
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`captured Basecamp processes survived teardown:\n${survivors.map((row) => row.command).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInspectorArtifacts(app, prefix) {
|
||||
const safeWriteJson = async (name, callback) => {
|
||||
try {
|
||||
const value = await callback();
|
||||
writeFileSync(join(artifactDir, `${prefix}-${name}.json`), JSON.stringify(value, null, 2));
|
||||
} catch (error) {
|
||||
writeFileSync(join(artifactDir, `${prefix}-${name}.error.txt`), `${error.stack || error}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const screenshot = await app.screenshot();
|
||||
if (screenshot?.image) {
|
||||
writeFileSync(join(artifactDir, `${prefix}.png`), Buffer.from(screenshot.image, "base64"));
|
||||
} else {
|
||||
writeFileSync(join(artifactDir, `${prefix}-screenshot.error.txt`), JSON.stringify(screenshot, null, 2));
|
||||
}
|
||||
} catch (error) {
|
||||
writeFileSync(join(artifactDir, `${prefix}-screenshot.error.txt`), `${error.stack || error}\n`);
|
||||
}
|
||||
await safeWriteJson("tree", () => app.getTree({ depth: 40 }));
|
||||
await safeWriteJson("interactive", () => app.listInteractive());
|
||||
}
|
||||
|
||||
const inspectorPort = await reserveInspectorPort();
|
||||
process.env.QML_INSPECTOR_HOST = "127.0.0.1";
|
||||
process.env.QML_INSPECTOR_PORT = String(inspectorPort);
|
||||
const { App, Inspector } = await import(resolve(qtMcpRoot, "test-framework/framework.mjs"));
|
||||
|
||||
const processLog = createWriteStream(join(artifactDir, "basecamp-process.log"), { flags: "w" });
|
||||
const child = spawn(appBin, ["--user-dir", userDir, "-platform", "offscreen"], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
LOGOS_USER_DIR: userDir,
|
||||
XDG_RUNTIME_DIR: runtimeDir,
|
||||
TMPDIR: runtimeDir,
|
||||
QML_INSPECTOR_HOST: "127.0.0.1",
|
||||
QML_INSPECTOR_PORT: String(inspectorPort),
|
||||
QT_QPA_PLATFORM: "offscreen",
|
||||
QT_FORCE_STDERR_LOGGING: "1",
|
||||
QT_LOGGING_RULES: "qt.*.debug=false;default.debug=true",
|
||||
},
|
||||
});
|
||||
child.stdout.pipe(processLog, { end: false });
|
||||
child.stderr.pipe(processLog, { end: false });
|
||||
let spawnError;
|
||||
child.once("error", (error) => { spawnError = error; });
|
||||
|
||||
let inspector;
|
||||
let app;
|
||||
let failure;
|
||||
let teardownStarted = false;
|
||||
|
||||
async function teardown() {
|
||||
if (teardownStarted) return;
|
||||
teardownStarted = true;
|
||||
inspector?.disconnect();
|
||||
await terminateOwnedProcesses(child);
|
||||
await new Promise((resolveEnd) => processLog.end(resolveEnd));
|
||||
}
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, () => {
|
||||
teardown().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
inspector = await waitFor("Basecamp QML inspector", async () => {
|
||||
if (spawnError) throw new FatalWaitError(`Basecamp failed to launch: ${spawnError.message}`);
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new FatalWaitError(`Basecamp exited early (code=${child.exitCode}, signal=${child.signalCode})`);
|
||||
}
|
||||
const candidate = new Inspector();
|
||||
await candidate.connect();
|
||||
return candidate;
|
||||
// Match Basecamp's own headless doc-test launch budget. A cold portable
|
||||
// macOS bundle can spend more than a minute relocating/starting its embedded
|
||||
// package-manager modules before MainWindow attaches the inspector.
|
||||
}, 300000, 500);
|
||||
app = new App(inspector);
|
||||
|
||||
await app.waitFor(
|
||||
() => app.expectTexts(["ETH ↔ LEZ Atomic Swap"]),
|
||||
{ timeout: 30000, interval: 500, description: "Atomic Swaps launcher entry" },
|
||||
);
|
||||
await app.click("ETH ↔ LEZ Atomic Swap", { exact: true });
|
||||
await app.waitFor(
|
||||
() => app.expectTexts(["LEZ Atomic Swap", "LIVE MARKET", "Market", "Config", "Maker", "Taker", "Refund", "History"]),
|
||||
{ timeout: 30000, interval: 500, description: "swap_ui view" },
|
||||
);
|
||||
|
||||
const topology = await waitFor("Basecamp module process topology", () => {
|
||||
const rows = descendantsOf(child.pid);
|
||||
const forbidden = rows.find((row) => row.command.includes("logos-standalone-app"));
|
||||
if (forbidden) throw new Error(`unexpected module-owned app host: ${forbidden.command}`);
|
||||
const delivery = moduleCommand(rows, "logos_host", "delivery_module");
|
||||
const swap = moduleCommand(rows, "logos_host", "swap");
|
||||
const ui = moduleCommand(rows, "ui-host", "swap_ui");
|
||||
return delivery && swap && ui ? { rows, delivery, swap, ui } : null;
|
||||
});
|
||||
writeFileSync(join(artifactDir, "process-tree.txt"), formatProcessTree(child.pid));
|
||||
|
||||
await app.waitFor(async () => {
|
||||
const connecting = await app.findByProperty("text", "Connecting to backend...");
|
||||
if (connecting.matches?.length) throw new Error("swap_ui backend is still connecting");
|
||||
}, { timeout: 30000, interval: 500, description: "swap_ui backend connection" });
|
||||
|
||||
const tabChecks = [
|
||||
["Config", ["Configuration", "Load Maker Env", "Load Taker Env"]],
|
||||
["Maker", ["Sell LEZ"]],
|
||||
["Taker", ["Buy LEZ"]],
|
||||
["Refund", ["Manual Refund"]],
|
||||
["History", ["Swap History"]],
|
||||
["Market", ["LIVE MARKET"]],
|
||||
];
|
||||
for (const [tab, texts] of tabChecks) {
|
||||
await app.click(tab, { exact: true });
|
||||
await app.waitFor(
|
||||
() => app.expectTexts(texts),
|
||||
{ timeout: 10000, interval: 300, description: `${tab} tab` },
|
||||
);
|
||||
}
|
||||
|
||||
await saveInspectorArtifacts(app, "success");
|
||||
console.log("Basecamp-native swap_ui smoke test passed");
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
writeFileSync(join(artifactDir, "failure.txt"), `${error.stack || error}\n`);
|
||||
if (child?.pid) {
|
||||
try { writeFileSync(join(artifactDir, "failure-process-tree.txt"), formatProcessTree(child.pid)); } catch {}
|
||||
}
|
||||
if (app) await saveInspectorArtifacts(app, "failure");
|
||||
} finally {
|
||||
try {
|
||||
await teardown();
|
||||
} catch (error) {
|
||||
failure ||= error;
|
||||
writeFileSync(join(artifactDir, "teardown-failure.txt"), `${error.stack || error}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) throw failure;
|
||||
Reference in New Issue
Block a user