fix: production onboarding, CI tests, container security, keyboard nav

Install & Onboarding:
- Remove DEV_MODE=true from production ISO service file (auto-created
  users, skipped password setup)
- Auto-install no longer overwrites rootfs service file with bad template
- Login.vue always checks auth.isSetup — shows password creation form
  on fresh install without requiring dev build flag
- Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes
- First-boot-containers sources image-versions.sh, runs podman as
  archipelago user (rootless), enables linger + podman.socket
- Correct volume ownership (100000:100000 for rootless UID mapping)

Container Security:
- FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access
- FileBrowser: add --read-only, /data volume for database, proper cmd args
- First-boot script matches backend config (security hardening + health check)

CI Pipeline:
- Add vue-tsc type check + vitest run to build-iso.yml (runs every push)
- Add post-install-tests.yml workflow (workflow_dispatch, SSH to target)
- Build report: set +eo pipefail, fix rootfs path, add || true guards
- Bundle run-post-install-tests.sh into ISO

E2E Test Suite (scripts/run-post-install-tests.sh):
- Phase 1: Install verification (files, services, podman, linger, DEV_MODE check)
- Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete)
- Phase 3: Container lifecycle (install 3 apps via package.install RPC,
  verify running, stop, verify stopped, restart, verify running, health)
- Phase 4: Log verification (first-boot log, diagnostics, journal errors)
- Correct package.install params: {"id", "dockerImage"}

Frontend:
- Fix backdrop-filter tab-switch bug (keep animations paused during rebuild)
- Dashboard glitch animations paused during tab-hidden
- Gamepad nav: auto-focus first container on route change
- Tab roving: Left/Right on role="tab" cycles and activates sibling tabs
- ContainerApps: data-controller-launch on running app cards
- 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-27 16:16:57 +00:00
parent bf14f9e5ad
commit 7cd4d90ed8
16 changed files with 1134 additions and 552 deletions

View File

@ -24,6 +24,12 @@ jobs:
- name: Build frontend
run: cd neode-ui && npm ci && npm run build
- name: Type check frontend
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run frontend tests
run: cd neode-ui && npx vitest run
- name: Cache Debian Live ISO
run: |
WORK_DIR="image-recipe/build/auto-installer"
@ -67,6 +73,7 @@ jobs:
if: always()
continue-on-error: true
run: |
set +eo pipefail
echo "══════════════════════════════════════════"
echo "BUILD REPORT"
echo "══════════════════════════════════════════"
@ -80,7 +87,7 @@ jobs:
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
echo ""
echo "── Rootfs contents check ──"
ROOTFS=$(ls image-recipe/build/auto-installer/rootfs.tar 2>/dev/null)
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
if [ -n "$ROOTFS" ]; then
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
@ -96,7 +103,7 @@ jobs:
fi
echo ""
echo "── ISO contents check ──"
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
if [ -n "$ISO" ]; then
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
ISO_MOUNT=$(mktemp -d)

View File

@ -0,0 +1,72 @@
name: Post-Install Tests
on:
workflow_dispatch:
inputs:
target:
description: 'Target node IP (e.g. 192.168.1.198)'
required: true
default: '192.168.1.198'
password:
description: 'Node password (or "auto" for fresh install)'
required: false
default: 'auto'
jobs:
post-install-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run post-install tests on target
run: |
TARGET="${{ github.event.inputs.target }}"
PASSWORD="${{ github.event.inputs.password }}"
if [ "$PASSWORD" = "auto" ]; then
PASSWORD="testpass123!"
fi
echo "══════════════════════════════════════════"
echo "Running post-install tests on $TARGET"
echo "══════════════════════════════════════════"
# Copy test script to target and run
sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \
scp -o StrictHostKeyChecking=no \
scripts/run-post-install-tests.sh \
archipelago@${TARGET}:/tmp/run-post-install-tests.sh
# Run tests (with sudo for service checks)
sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \
ssh -o StrictHostKeyChecking=no \
archipelago@${TARGET} \
"sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'"
frontend-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: cd neode-ui && npm ci
- name: Type check
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run tests
run: cd neode-ui && npx vitest run
- name: Audit dependencies
run: cd neode-ui && npm audit --omit=dev

View File

@ -97,7 +97,11 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
],
// Minimal apps (searxng, filebrowser, etc.) need no extra caps
// FileBrowser needs DAC_OVERRIDE to read/write volume files under rootless podman
"filebrowser" => vec![
"--cap-add=DAC_OVERRIDE".to_string(),
],
// Minimal apps (searxng, etc.) need no extra caps
_ => vec![],
}
}

View File

@ -385,17 +385,19 @@ NGINXCONF
cat > "$WORK_DIR/archipelago.service" <<'SYSTEMDSERVICE'
[Unit]
Description=Archipelago Backend
After=network-online.target
After=network-online.target archipelago-setup-tor.service
Wants=network-online.target
[Service]
Type=simple
User=root
User=archipelago
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
Environment="ARCHIPELAGO_DEV_MODE=true"
Environment="XDG_RUNTIME_DIR=/run/user/1000"
ExecStartPre=/bin/bash -c 'mkdir -p /run/user/1000 && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
ProtectHome=no
[Install]
WantedBy=multi-user.target
@ -921,25 +923,56 @@ if [ "$UNBUNDLED" = "1" ]; then
#!/bin/bash
# Minimal first-boot: create FileBrowser container only (unbundled ISO)
set -e
DOCKER="podman"
LOG="/var/log/archipelago-first-boot.log"
echo "[$(date)] Starting minimal first-boot (unbundled)..." >> "$LOG"
# Create Cloud storage directories
mkdir -p /var/lib/archipelago/filebrowser
mkdir -p /var/lib/archipelago/data/cloud/{Documents,Photos,Music,Videos,Downloads}
chown -R 1000:1000 /var/lib/archipelago/filebrowser
chown -R 1000:1000 /var/lib/archipelago/data
# Source image versions (provides $FILEBROWSER_IMAGE etc.)
for f in /opt/archipelago/scripts/image-versions.sh /home/archipelago/archy/scripts/image-versions.sh; do
if [ -f "$f" ]; then
source "$f"
echo "[$(date)] Sourced image versions from $f" >> "$LOG"
break
fi
done
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
echo "[$(date)] Creating FileBrowser container..." >> "$LOG"
$DOCKER run -d --name filebrowser --restart unless-stopped \
--health-cmd="curl -sf http://localhost:80/ || exit 1" \
if [ -z "$FILEBROWSER_IMAGE" ]; then
echo "[$(date)] ERROR: FILEBROWSER_IMAGE not set — image-versions.sh missing or incomplete" >> "$LOG"
exit 1
fi
# Create Cloud storage directories (as root, then fix ownership for rootless podman)
mkdir -p /var/lib/archipelago/filebrowser
mkdir -p /var/lib/archipelago/filebrowser-data
mkdir -p /var/lib/archipelago/data/cloud/{Documents,Photos,Music,Videos,Downloads}
# Container UID 0 maps to host UID 100000 under rootless podman (subuid mapping)
chown -R 100000:100000 /var/lib/archipelago/filebrowser
chown -R 100000:100000 /var/lib/archipelago/filebrowser-data
chown -R 100000:100000 /var/lib/archipelago/data
# Enable linger so rootless podman containers survive logout
loginctl enable-linger archipelago 2>/dev/null || true
# Ensure podman socket is active for archipelago user
runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && systemctl --user enable --now podman.socket' 2>>"$LOG" || true
# Create FileBrowser container as archipelago user (rootless podman)
if ! runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman ps -a --format "{{.Names}}"' 2>/dev/null | grep -q filebrowser; then
echo "[$(date)] Creating FileBrowser container ($FILEBROWSER_IMAGE)..." >> "$LOG"
runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman run -d --name filebrowser --restart unless-stopped \
--cap-drop=ALL \
--cap-add=DAC_OVERRIDE \
--security-opt=no-new-privileges:true \
--read-only \
--tmpfs=/tmp:rw,noexec,nosuid,size=64m \
--health-cmd='curl -sf http://localhost:80/ || exit 1' \
--health-interval=30s --health-timeout=5s --health-retries=3 \
--memory=256m \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
"$FILEBROWSER_IMAGE" 2>>"$LOG" && \
-v /var/lib/archipelago/filebrowser-data:/data \
-v /var/lib/archipelago/data/cloud:/srv/cloud \
$FILEBROWSER_IMAGE \
--database=/data/database.db --root=/srv --address=0.0.0.0 --port=80" 2>>"$LOG" && \
echo "[$(date)] FileBrowser created successfully" >> "$LOG" || \
echo "[$(date)] WARNING: FileBrowser creation failed" >> "$LOG"
fi
@ -1001,6 +1034,11 @@ if [ -f "$SCRIPT_DIR/../scripts/run-e2e-tests.sh" ]; then
chmod +x "$ARCH_DIR/scripts/run-e2e-tests.sh"
echo " ✅ Bundled E2E test script for post-install validation"
fi
if [ -f "$SCRIPT_DIR/../scripts/run-post-install-tests.sh" ]; then
cp "$SCRIPT_DIR/../scripts/run-post-install-tests.sh" "$ARCH_DIR/scripts/"
chmod +x "$ARCH_DIR/scripts/run-post-install-tests.sh"
echo " ✅ Bundled post-install test suite"
fi
# Bundle self-update script and image-versions for update system
if [ -f "$SCRIPT_DIR/../scripts/self-update.sh" ]; then
@ -1411,11 +1449,13 @@ mkdir -p /mnt/target/var/lib/archipelago/tor-config
mkdir -p /mnt/target/var/lib/archipelago/identities
mkdir -p /mnt/target/var/lib/archipelago/lnd
# Copy E2E test script for post-install validation
if [ -f "$BOOT_MEDIA/archipelago/scripts/run-e2e-tests.sh" ]; then
cp "$BOOT_MEDIA/archipelago/scripts/run-e2e-tests.sh" /mnt/target/opt/archipelago/scripts/
chmod +x /mnt/target/opt/archipelago/scripts/run-e2e-tests.sh
fi
# Copy test scripts for post-install validation
for test_script in run-e2e-tests.sh run-post-install-tests.sh; do
if [ -f "$BOOT_MEDIA/archipelago/scripts/$test_script" ]; then
cp "$BOOT_MEDIA/archipelago/scripts/$test_script" /mnt/target/opt/archipelago/scripts/
chmod +x /mnt/target/opt/archipelago/scripts/$test_script
fi
done
# Copy self-update script
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
@ -1427,6 +1467,15 @@ if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
chmod +x /mnt/target/home/archipelago/archy/scripts/self-update.sh
fi
# Copy image-versions.sh (needed by first-boot-containers and updates)
if [ -f "$BOOT_MEDIA/archipelago/scripts/image-versions.sh" ]; then
cp "$BOOT_MEDIA/archipelago/scripts/image-versions.sh" /mnt/target/opt/archipelago/scripts/
chmod +x /mnt/target/opt/archipelago/scripts/image-versions.sh
# Also place in home for container scripts to find
mkdir -p /mnt/target/home/archipelago/archy/scripts
cp "$BOOT_MEDIA/archipelago/scripts/image-versions.sh" /mnt/target/home/archipelago/archy/scripts/
fi
# Clone repo for git-based updates (first-boot will have network)
# Create a script that runs on first boot to clone the repo
cat > /mnt/target/opt/archipelago/scripts/setup-git-updates.sh <<'GITSETUP'
@ -1513,26 +1562,12 @@ fi
PROFILE
chmod +x /mnt/target/etc/profile.d/archipelago.sh
# Systemd service: User=root required for Podman container access
cat > /mnt/target/etc/systemd/system/archipelago.service <<'SERVICE'
[Unit]
Description=Archipelago Backend
After=network-online.target archipelago-setup-tor.service
Wants=network-online.target
[Service]
Type=simple
User=root
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
Environment="ARCHIPELAGO_DEV_MODE=true"
ExecStartPre=/bin/bash -c 'mkdir -p /etc/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk \"{print \$1}\")" > /etc/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE
# Systemd service: use the production version from rootfs (configs/archipelago.service)
# Do NOT overwrite — the rootfs already has the correct User=archipelago, no DEV_MODE version
if [ ! -f /mnt/target/etc/systemd/system/archipelago.service ]; then
echo " WARNING: archipelago.service missing from rootfs — copying from ISO"
cp "$BOOT_MEDIA/archipelago/configs/archipelago.service" /mnt/target/etc/systemd/system/archipelago.service 2>/dev/null || true
fi
# Claude API proxy — middleware that injects max_tokens, strips invalid fields
# API key must be set after install via setup-aiui-server.sh or manually
@ -1942,6 +1977,19 @@ if [ ! -f /mnt/target/etc/archipelago/ssl/archipelago.crt ]; then
echo " Generated self-signed SSL certificate"
fi
# Enable linger for rootless podman (containers survive logout)
mkdir -p /mnt/target/var/lib/systemd/linger
touch /mnt/target/var/lib/systemd/linger/archipelago
# Enable podman socket for archipelago user (activated on first login/boot)
mkdir -p /mnt/target/home/archipelago/.config/systemd/user/sockets.target.wants
ln -sf /usr/lib/systemd/user/podman.socket /mnt/target/home/archipelago/.config/systemd/user/sockets.target.wants/podman.socket 2>/dev/null || true
chown -R 1000:1000 /mnt/target/home/archipelago/.config 2>/dev/null || true
# Ensure /run/user/1000 is created at boot for podman socket
mkdir -p /mnt/target/etc/tmpfiles.d
echo 'd /run/user/1000 0700 archipelago archipelago -' > /mnt/target/etc/tmpfiles.d/archipelago-runtime.conf
# Enable services
chroot /mnt/target systemctl enable archipelago.service 2>/dev/null || true
chroot /mnt/target systemctl enable nginx.service 2>/dev/null || true

View File

@ -176,15 +176,17 @@ function onVisibilityChange() {
if (document.hidden) {
document.documentElement.classList.add('tab-hidden')
} else {
// Step 1: kill all backdrop-filters (forces compositor to drop those layers)
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
document.documentElement.classList.add('no-backdrop')
document.documentElement.classList.remove('tab-hidden')
// Step 2: next frame, re-enable (compositor builds fresh layers)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
// Use setTimeout Chromium batches rAFs on tab return
setTimeout(() => {
document.documentElement.classList.remove('no-backdrop')
// Step 3: resume animations after backdrop-filter layers are established
requestAnimationFrame(() => {
document.documentElement.classList.remove('tab-hidden')
})
})
}, 50)
}
}

View File

@ -6,7 +6,7 @@ vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
@ -34,9 +34,17 @@ function jsonResponse(body: unknown, status = 200): Response {
}
}
/** Set up authenticated state — bypasses jsdom cookie path restrictions */
function setAuthenticated() {
;(fileBrowserClient as any)._authenticated = true
document.cookie = 'auth=test-token'
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
describe('login', () => {
@ -75,9 +83,7 @@ describe('FileBrowserClient', () => {
describe('listDirectory', () => {
it('lists items in a directory', async () => {
// Ensure authenticated first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
const mockItems = {
items: [
@ -98,8 +104,7 @@ describe('FileBrowserClient', () => {
})
it('adds leading slash if missing', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
@ -110,8 +115,7 @@ describe('FileBrowserClient', () => {
})
it('throws on non-OK response', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
@ -135,8 +139,7 @@ describe('FileBrowserClient', () => {
describe('upload', () => {
it('uploads a file to the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
@ -151,8 +154,7 @@ describe('FileBrowserClient', () => {
})
it('throws on upload failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
@ -163,8 +165,7 @@ describe('FileBrowserClient', () => {
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@ -176,8 +177,7 @@ describe('FileBrowserClient', () => {
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
@ -187,8 +187,7 @@ describe('FileBrowserClient', () => {
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@ -200,8 +199,7 @@ describe('FileBrowserClient', () => {
})
it('throws on failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
@ -211,9 +209,7 @@ describe('FileBrowserClient', () => {
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
// Login first
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
const mockData = {
items: [
@ -235,8 +231,7 @@ describe('FileBrowserClient', () => {
})
it('returns zeros on failed request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
@ -264,8 +259,7 @@ describe('FileBrowserClient', () => {
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
@ -278,8 +272,7 @@ describe('FileBrowserClient', () => {
})
it('throws on rename failure', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
await fileBrowserClient.login()
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))

View File

@ -45,53 +45,48 @@ vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
// Note: The composable uses onMounted/onBeforeUnmount, so full integration tests
// would require a mounted component with Pinia and Router. We test helper logic directly.
// ─── Module Export Tests ────────────────────────────────────────
describe('useControllerNav - helper functions', () => {
describe('useControllerNav - module', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockRoute.path = '/dashboard'
// Mock navigator.getGamepads
Object.defineProperty(navigator, 'getGamepads', {
value: vi.fn().mockReturnValue([null, null, null, null]),
configurable: true,
writable: true,
})
})
afterEach(() => { vi.useRealTimers() })
afterEach(() => {
vi.useRealTimers()
})
// Test the module exports via dynamic import to validate structure
it('exports useControllerNav as a function', async () => {
const mod = await import('../useControllerNav')
expect(typeof mod.useControllerNav).toBe('function')
})
})
describe('useControllerNav - nav key classification', () => {
it('classifies arrow keys and Enter/Escape as nav keys', () => {
// ─── Nav Key Classification ─────────────────────────────────────
describe('useControllerNav - nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('ArrowUp')).toBe(true)
expect(navKeys.includes('ArrowDown')).toBe(true)
expect(navKeys.includes('ArrowLeft')).toBe(true)
expect(navKeys.includes('ArrowRight')).toBe(true)
expect(navKeys.includes('Enter')).toBe(true)
expect(navKeys.includes('Escape')).toBe(true)
it('classifies all arrow keys, Enter, and Escape as nav keys', () => {
for (const key of navKeys) {
expect(navKeys.includes(key)).toBe(true)
}
})
it('does not classify regular keys as nav keys', () => {
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
expect(navKeys.includes('a')).toBe(false)
expect(navKeys.includes('Space')).toBe(false)
expect(navKeys.includes('Tab')).toBe(false)
it('rejects non-nav keys', () => {
for (const key of ['a', 'Space', 'Tab', 'Shift', 'F1', 'Delete']) {
expect(navKeys.includes(key)).toBe(false)
}
})
})
it('recognizes detail page patterns', () => {
// ─── Route Pattern Tests ────────────────────────────────────────
describe('useControllerNav - route patterns', () => {
it('recognizes detail page patterns for Escape-back behavior', () => {
const pattern = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/
expect(pattern.test('/apps/bitcoin')).toBe(true)
expect(pattern.test('/marketplace/electrs')).toBe(true)
@ -100,7 +95,7 @@ describe('useControllerNav - nav key classification', () => {
expect(pattern.test('/apps')).toBe(false)
})
it('recognizes page type patterns', () => {
it('recognizes all page type patterns for right-arrow targets', () => {
expect(/^\/dashboard(\/)?$/.test('/dashboard')).toBe(true)
expect(/^\/dashboard(\/)?$/.test('/dashboard/')).toBe(true)
expect(/^\/dashboard\/(apps|marketplace)(\/|$)/.test('/dashboard/apps')).toBe(true)
@ -112,132 +107,206 @@ describe('useControllerNav - nav key classification', () => {
})
})
describe('useControllerNav - spatial navigation helpers', () => {
// Test the internal helper functions indirectly via the FOCUSABLE_SELECTOR concept
// ─── Focusable Element Detection ────────────────────────────────
it('identifies focusable elements', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Click'
const link = document.createElement('a')
link.href = '/test'
link.textContent = 'Link'
const disabledBtn = document.createElement('button')
disabledBtn.disabled = true
disabledBtn.textContent = 'Disabled'
const input = document.createElement('input')
describe('useControllerNav - focusable elements', () => {
afterEach(() => { document.body.innerHTML = '' })
container.appendChild(button)
container.appendChild(link)
container.appendChild(disabledBtn)
container.appendChild(input)
document.body.appendChild(container)
const focusable = container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
it('finds buttons, links, and inputs as focusable', () => {
document.body.innerHTML = `
<div>
<button>Click</button>
<a href="/test">Link</a>
<input type="text" />
<button disabled>Disabled</button>
</div>
`
const focusable = document.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled])'
)
// Should find button, link, and input but NOT disabled button
expect(focusable.length).toBe(3)
document.body.removeChild(container)
})
it('respects data-controller-ignore attribute', () => {
const container = document.createElement('div')
const button = document.createElement('button')
button.textContent = 'Visible'
const ignoredBtn = document.createElement('button')
ignoredBtn.textContent = 'Ignored'
ignoredBtn.setAttribute('data-controller-ignore', '')
container.appendChild(button)
container.appendChild(ignoredBtn)
document.body.appendChild(container)
const focusable = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => !el.hasAttribute('data-controller-ignore'))
it('finds elements with tabindex as focusable', () => {
document.body.innerHTML = `
<div tabindex="0">Container</div>
<div tabindex="-1">Hidden</div>
<div>Not focusable</div>
`
const focusable = document.querySelectorAll('[tabindex]:not([tabindex="-1"])')
expect(focusable.length).toBe(1)
expect(focusable[0]?.textContent).toBe('Visible')
document.body.removeChild(container)
})
it('identifies sidebar and main zones', () => {
const sidebar = document.createElement('div')
sidebar.setAttribute('data-controller-zone', 'sidebar')
const main = document.createElement('div')
main.setAttribute('data-controller-zone', 'main')
const sideBtn = document.createElement('button')
sideBtn.textContent = 'Nav'
sidebar.appendChild(sideBtn)
const mainBtn = document.createElement('button')
mainBtn.textContent = 'Content'
main.appendChild(mainBtn)
document.body.appendChild(sidebar)
document.body.appendChild(main)
// isInZone check
expect(sideBtn.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
expect(mainBtn.closest('[data-controller-zone="main"]')).toBeTruthy()
expect(sideBtn.closest('[data-controller-zone="main"]')).toBeNull()
document.body.removeChild(sidebar)
document.body.removeChild(main)
it('finds data-controller-container as focusable', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">Card 1</div>
<div data-controller-container tabindex="0">Card 2</div>
<div>Regular div</div>
`
const containers = document.querySelectorAll('[data-controller-container]')
expect(containers.length).toBe(2)
})
it('identifies container elements', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
const innerBtn = document.createElement('button')
innerBtn.textContent = 'Inner'
container.appendChild(innerBtn)
document.body.appendChild(container)
// isInsideContainer check
expect(innerBtn.closest('[data-controller-container]')).toBe(container)
expect(container.closest('[data-controller-container]')).toBe(container)
document.body.removeChild(container)
})
it('finds inner focusable elements within containers', () => {
const container = document.createElement('div')
container.setAttribute('data-controller-container', '')
container.tabIndex = 0
const btn1 = document.createElement('button')
btn1.textContent = 'Action 1'
const btn2 = document.createElement('button')
btn2.textContent = 'Action 2'
container.appendChild(btn1)
container.appendChild(btn2)
document.body.appendChild(container)
const inner = Array.from(
container.querySelectorAll<HTMLElement>('button:not([disabled])')
).filter(el => el !== container)
expect(inner.length).toBe(2)
document.body.removeChild(container)
it('excludes data-controller-ignore elements', () => {
document.body.innerHTML = `
<button>Visible</button>
<button data-controller-ignore>Ignored</button>
<div data-controller-ignore><button>Also ignored</button></div>
`
const all = Array.from(document.querySelectorAll<HTMLElement>('button:not([disabled])')).filter(
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
)
expect(all.length).toBe(1)
expect(all[0]?.textContent).toBe('Visible')
})
})
describe('useControllerNav - gamepad detection', () => {
beforeEach(() => {
vi.clearAllMocks()
// ─── Zone Detection ─────────────────────────────────────────────
describe('useControllerNav - zones', () => {
afterEach(() => { document.body.innerHTML = '' })
it('sidebar elements belong to sidebar zone', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/home">Home</a></div>
<div data-controller-zone="main"><button>Action</button></div>
`
const link = document.querySelector('a')!
const btn = document.querySelector('button')!
expect(link.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
expect(btn.closest('[data-controller-zone="main"]')).toBeTruthy()
expect(link.closest('[data-controller-zone="main"]')).toBeNull()
})
it('main elements belong to main zone', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar"><a href="/">Nav</a></div>
<div data-controller-zone="main"><div data-controller-container tabindex="0"><button>Inner</button></div></div>
`
const inner = document.querySelector('button')!
expect(inner.closest('[data-controller-zone="main"]')).toBeTruthy()
})
})
// ─── Container Drill-in/Drill-out ───────────────────────────────
describe('useControllerNav - container behavior', () => {
afterEach(() => { document.body.innerHTML = '' })
it('container elements are identified via data-controller-container', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<button>Stop</button>
<button>Launch</button>
</div>
`
const container = document.querySelector('[data-controller-container]')
expect(container).toBeTruthy()
expect(container?.getAttribute('tabindex')).toBe('0')
})
it('inner buttons are found within containers', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<button>Stop</button>
<button data-controller-launch-btn>Launch</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
const inner = Array.from(container.querySelectorAll<HTMLElement>('button:not([disabled])')).filter(
el => el !== container
)
expect(inner.length).toBe(2)
})
it('isInsideContainer detects when element is nested in a container', () => {
document.body.innerHTML = `
<div data-controller-container tabindex="0">
<button id="inner">Action</button>
</div>
<button id="outer">Outside</button>
`
const inner = document.getElementById('inner')!
const outer = document.getElementById('outer')!
const innerContainer = inner.closest('[data-controller-container]')
expect(innerContainer).toBeTruthy()
expect(innerContainer !== inner).toBe(true)
expect(outer.closest('[data-controller-container]')).toBeNull()
})
it('data-controller-launch marks a card for Enter=launch behavior', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-launch tabindex="0">
<button data-controller-launch-btn>Launch</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-launch')).toBe(true)
const btn = container.querySelector('[data-controller-launch-btn]')
expect(btn).toBeTruthy()
})
it('data-controller-install marks a card for Enter=install behavior', () => {
document.body.innerHTML = `
<div data-controller-container data-controller-install tabindex="0">
<button data-controller-install-btn>Install</button>
</div>
`
const container = document.querySelector('[data-controller-container]')!
expect(container.hasAttribute('data-controller-install')).toBe(true)
const btn = container.querySelector('[data-controller-install-btn]')
expect(btn).toBeTruthy()
})
})
// ─── Spatial Navigation (findNearestInDirection) ────────────────
describe('useControllerNav - spatial navigation logic', () => {
afterEach(() => { document.body.innerHTML = '' })
it('direction filtering works correctly', () => {
// Simulate the direction check logic from findNearestInDirection
const fromRect = { left: 200, right: 350, top: 0, bottom: 150, width: 150, height: 150 }
const threshold = 50
// Element to the left
const leftRect = { left: 0, right: 150, top: 0, bottom: 150 }
expect(leftRect.right <= fromRect.left + threshold).toBe(true) // is to the left
// Element to the right
const rightRect = { left: 400, right: 550, top: 0, bottom: 150 }
expect(rightRect.left >= fromRect.right - threshold).toBe(true) // is to the right
// Element below
const belowRect = { left: 200, right: 350, top: 200, bottom: 350 }
expect(belowRect.top >= fromRect.bottom - threshold).toBe(true) // is below
// Element above (from below position)
expect(fromRect.bottom <= belowRect.top + threshold).toBe(true) // fromRect is above belowRect
})
it('overlap scoring prefers aligned elements', () => {
// Two elements to the right: one aligned, one offset
const fromRect = { left: 0, right: 150, top: 50, bottom: 200, width: 150, height: 150 }
// Aligned (same row, full overlap on Y axis)
const alignedRect = { left: 200, right: 350, top: 50, bottom: 200, width: 150, height: 150 }
const alignedOverlap = Math.max(0, Math.min(fromRect.bottom, alignedRect.bottom) - Math.max(fromRect.top, alignedRect.top))
// Offset (partially overlapping on Y axis)
const offsetRect = { left: 200, right: 350, top: 160, bottom: 310, width: 150, height: 150 }
const offsetOverlap = Math.max(0, Math.min(fromRect.bottom, offsetRect.bottom) - Math.max(fromRect.top, offsetRect.top))
expect(alignedOverlap).toBeGreaterThan(offsetOverlap) // aligned element wins
expect(alignedOverlap).toBe(150) // full overlap
expect(offsetOverlap).toBe(40) // partial overlap
})
})
// ─── Gamepad Detection ──────────────────────────────────────────
describe('useControllerNav - gamepad', () => {
it('counts connected gamepads', () => {
const gamepads = [
{ connected: true } as Gamepad,
@ -245,22 +314,195 @@ describe('useControllerNav - gamepad detection', () => {
{ connected: true } as Gamepad,
null,
]
const count = gamepads.filter((g) => g?.connected).length
expect(count).toBe(2)
expect(gamepads.filter(g => g?.connected).length).toBe(2)
})
it('handles null gamepad list', () => {
// Simulate navigator.getGamepads returning null (some browsers)
function getCount(gp: (Gamepad | null)[] | null): number {
return gp ? gp.filter((g) => g?.connected).length : 0
}
const getCount = (gp: (Gamepad | null)[] | null): number =>
gp ? gp.filter(g => g?.connected).length : 0
expect(getCount(null)).toBe(0)
})
it('handles empty gamepad list', () => {
it('handles all-null gamepad list', () => {
const gamepads: (Gamepad | null)[] = [null, null, null, null]
const count = Array.from(gamepads).filter((g) => g?.connected).length
expect(count).toBe(0)
expect(Array.from(gamepads).filter(g => g?.connected).length).toBe(0)
})
})
// ─── Sidebar Navigation ─────────────────────────────────────────
describe('useControllerNav - sidebar behavior', () => {
afterEach(() => { document.body.innerHTML = '' })
it('sidebar has linear up/down navigation with wrap', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard" class="nav-tab-active">Home</a>
<a href="/dashboard/apps">Apps</a>
<a href="/dashboard/cloud">Cloud</a>
<button>Logout</button>
</div>
`
const items = document.querySelectorAll('[data-controller-zone="sidebar"] a, [data-controller-zone="sidebar"] button')
expect(items.length).toBe(4)
// Wrap: last→first
const lastIdx = items.length - 1
const nextIdx = lastIdx >= items.length - 1 ? 0 : lastIdx + 1
expect(nextIdx).toBe(0) // wraps to Home
// Wrap: first→last
const firstIdx = 0
const prevIdx = firstIdx <= 0 ? items.length - 1 : firstIdx - 1
expect(prevIdx).toBe(3) // wraps to Logout
})
it('left arrow from main goes to active sidebar tab', () => {
document.body.innerHTML = `
<div data-controller-zone="sidebar">
<a href="/dashboard">Home</a>
<a href="/dashboard/apps" class="nav-tab-active">Apps</a>
</div>
<div data-controller-zone="main">
<button id="mainBtn">Action</button>
</div>
`
const activeTab = document.querySelector('.nav-tab-active')
expect(activeTab).toBeTruthy()
expect(activeTab?.textContent).toBe('Apps')
})
})
// ─── Auto-focus Behavior ─────────────────────────────────────────
describe('useControllerNav - auto-focus', () => {
afterEach(() => { document.body.innerHTML = '' })
it('first container in main zone is the auto-focus target', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="first">Card 1</div>
<div data-controller-container tabindex="0" id="second">Card 2</div>
</div>
`
const mainZone = document.querySelector('[data-controller-zone="main"]')
const firstContainer = mainZone?.querySelector<HTMLElement>('[data-controller-container]')
expect(firstContainer?.id).toBe('first')
})
it('does not auto-focus when input is active', () => {
document.body.innerHTML = `
<input id="search" type="text" />
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card</div>
</div>
`
const input = document.getElementById('search') as HTMLInputElement
input.focus()
// Auto-focus should skip when input is active
expect(document.activeElement?.tagName).toBe('INPUT')
})
})
// ─── Tab Roving Behavior ─────────────────────────────────────────
describe('useControllerNav - tab roving', () => {
afterEach(() => { document.body.innerHTML = '' })
it('role="tab" elements are found as siblings within tablist', () => {
document.body.innerHTML = `
<div role="tablist">
<button role="tab" id="tab1">Dashboard</button>
<button role="tab" id="tab2">Setup</button>
</div>
`
const tabs = document.querySelectorAll('[role="tab"]')
expect(tabs.length).toBe(2)
const tablist = tabs[0]?.closest('[role="tablist"]')
expect(tablist).toBeTruthy()
})
it('tab roving cycles right: first → second → first', () => {
const tabs = ['tab1', 'tab2']
// Right from index 0
expect((0 + 1) % tabs.length).toBe(1)
// Right from index 1 (wraps)
expect((1 + 1) % tabs.length).toBe(0)
})
it('tab roving cycles left: second → first → second', () => {
const tabs = ['tab1', 'tab2']
// Left from index 1
expect((1 - 1 + tabs.length) % tabs.length).toBe(0)
// Left from index 0 (wraps)
expect((0 - 1 + tabs.length) % tabs.length).toBe(1)
})
it('tab roving falls back to parent when no role="tablist" wrapper', () => {
document.body.innerHTML = `
<div class="mode-switcher">
<button role="tab" id="tab1">Dashboard</button>
<button role="tab" id="tab2">Setup</button>
</div>
`
const tab = document.getElementById('tab1')!
// No role="tablist" — falls back to parentElement
const tablist = tab.closest('[role="tablist"]') ?? tab.parentElement
expect(tablist).toBeTruthy()
const tabs = tablist!.querySelectorAll('[role="tab"]:not([disabled])')
expect(tabs.length).toBe(2)
})
})
// ─── Scroll Behavior ──────────────────────────────────────────────
describe('useControllerNav - scroll helpers', () => {
it('focused elements have scrollIntoView method', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">Card 1</div>
</div>
`
const card = document.querySelector('[data-controller-container]') as HTMLElement
// jsdom provides scrollIntoView as a no-op
expect(card).toBeTruthy()
expect(card.focus).toBeDefined()
})
})
// ─── Container Grid Navigation ────────────────────────────────────
describe('useControllerNav - grid navigation patterns', () => {
afterEach(() => { document.body.innerHTML = '' })
it('marketplace 3-column grid has correct spatial relationships', () => {
// Simulate a 3-column grid (like marketplace)
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0" id="c1" style="position:absolute;left:0;top:0;width:200px;height:200px">App 1</div>
<div data-controller-container tabindex="0" id="c2" style="position:absolute;left:220px;top:0;width:200px;height:200px">App 2</div>
<div data-controller-container tabindex="0" id="c3" style="position:absolute;left:440px;top:0;width:200px;height:200px">App 3</div>
<div data-controller-container tabindex="0" id="c4" style="position:absolute;left:0;top:220px;width:200px;height:200px">App 4</div>
<div data-controller-container tabindex="0" id="c5" style="position:absolute;left:220px;top:220px;width:200px;height:200px">App 5</div>
<div data-controller-container tabindex="0" id="c6" style="position:absolute;left:440px;top:220px;width:200px;height:200px">App 6</div>
</div>
`
const containers = document.querySelectorAll('[data-controller-container]')
expect(containers.length).toBe(6)
// Row 1: c1, c2, c3; Row 2: c4, c5, c6
expect(containers[0]?.id).toBe('c1')
expect(containers[3]?.id).toBe('c4')
})
it('home 2-column grid has correct container count', () => {
document.body.innerHTML = `
<div data-controller-zone="main">
<div data-controller-container tabindex="0">My Apps</div>
<div data-controller-container tabindex="0">Wallet</div>
<div data-controller-container tabindex="0">System</div>
</div>
`
const containers = document.querySelectorAll('[data-controller-container]')
expect(containers.length).toBe(3)
})
})

View File

@ -149,7 +149,7 @@ describe('useMessageToast', () => {
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
})
it('stops polling on 401 error', async () => {

View File

@ -283,6 +283,28 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
isControllerActive.value = gamepadCount.value > 0
}, 3000)
// Tab roving: Left/Right on role="tab" switches to sibling tab and activates it
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && activeEl?.getAttribute('role') === 'tab') {
const tablist = activeEl.closest('[role="tablist"]') ?? activeEl.parentElement
if (tablist) {
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])'))
const idx = tabs.indexOf(activeEl)
if (idx >= 0) {
const nextIdx = e.key === 'ArrowRight'
? (idx + 1) % tabs.length
: (idx - 1 + tabs.length) % tabs.length
const next = tabs[nextIdx]
if (next) {
playNavSound('move')
next.focus()
next.click()
e.preventDefault()
return
}
}
}
}
const sidebarEls = getElementsInZone('sidebar')
const mainEls = getElementsInZone('main')
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
@ -460,6 +482,28 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
}
}
/** Auto-focus first main container on route change (game-style: always have something selected) */
function autoFocusMain() {
// Don't steal focus from inputs or modals
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
if (document.querySelector('[role="dialog"]')) return
requestAnimationFrame(() => {
const mainZone = document.querySelector('[data-controller-zone="main"]')
if (!mainZone) return
const firstContainer = mainZone.querySelector<HTMLElement>('[data-controller-container]')
if (firstContainer) {
firstContainer.focus({ preventScroll: true })
}
})
}
watch(() => route.path, () => {
// Small delay to let Vue render the new route's DOM
setTimeout(autoFocusMain, 150)
})
onMounted(() => {
checkGamepads()
window.addEventListener('keydown', handleKeyDown, true)
@ -467,6 +511,8 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
window.addEventListener('gamepadconnected', handleGamepadConnected)
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
pollIntervalId = setInterval(handleGamepadInput, 500)
// Initial auto-focus after mount
setTimeout(autoFocusMain, 300)
})
onBeforeUnmount(() => {

View File

@ -27,6 +27,7 @@
v-for="app in bundledApps"
:key="app.id"
data-controller-container
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
tabindex="0"
class="glass-card p-6 hover:bg-white/5 transition-colors"
>
@ -134,6 +135,7 @@
</button>
<button
type="button"
data-controller-launch-btn
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
@click="launchApp(app)"
>

View File

@ -12,6 +12,7 @@
<!-- Desktop: tabs inline with header -->
<div
v-if="!uiMode.isChat"
role="tablist"
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
@ -48,8 +49,8 @@
<template v-if="!uiMode.isChat">
<!-- Mobile: full-width tabs -->
<div
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
role="tablist"
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
>
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>

View File

@ -244,10 +244,8 @@ const startupProgress = ref(0)
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
// Check if we're in setup mode (original StartOS node setup)
const isSetupMode = computed(() => {
return import.meta.env.VITE_DEV_MODE === 'setup'
})
// Whether we're in setup mode (no password created yet)
const isSetupMode = ref(false)
// Whether the login form should be disabled (server not ready)
const formDisabled = computed(() => !serverReady.value)
@ -339,16 +337,14 @@ onMounted(async () => {
await pollServerStartup()
}
// Only check setup mode after server is confirmed ready
if (isSetupMode.value) {
// Check if password has been set up show setup form if not
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
isSetupMode.value = !isSetup.value
} catch {
isSetup.value = false
}
} else {
isSetup.value = true
isSetupMode.value = true
}
})

View File

@ -43,10 +43,12 @@ vi.mock('@/components/AnimatedLogo.vue', () => ({
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
}))
const pushMock = vi.fn()
const pushMock = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ query: {} }),
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
createWebHistory: vi.fn(),
}))
// Stub fetch for server health check

View File

@ -1,317 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
// Mock rpc-client before importing anything that uses it
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ backups: [] }),
login: vi.fn(),
logout: vi.fn(),
changePassword: vi.fn(),
totpStatus: vi.fn().mockResolvedValue({ enabled: false }),
totpSetupBegin: vi.fn(),
totpSetupConfirm: vi.fn(),
totpDisable: vi.fn(),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
},
}))
// Mock websocket module
vi.mock('@/api/websocket', () => ({
wsClient: {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
subscribe: vi.fn(),
isConnected: vi.fn().mockReturnValue(false),
onConnectionStateChange: vi.fn(),
},
applyDataPatch: vi.fn(),
}))
// Stub the ControllerIndicator component
vi.mock('@/components/ControllerIndicator.vue', () => ({
default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }),
}))
// Mock useModalKeyboard composable
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
// Stub vue-router
const pushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushMock,
}),
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
}))
// Stub global fetch for the Claude status check in onMounted
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available')))
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import Settings from '../Settings.vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
const mockedRpc = vi.mocked(rpcClient)
function mountSettings(storeOverrides?: Partial<ReturnType<typeof useAppStore>>): VueWrapper {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
// Set default store state for tests
store.isAuthenticated = true
store.$patch({
data: {
'server-info': {
id: 'test-node',
version: '0.1.0-alpha',
name: 'Test Node',
pubkey: 'test-pubkey',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': '192.168.1.100',
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
if (storeOverrides) {
store.$patch(storeOverrides as Record<string, unknown>)
}
return shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
}
describe('Settings View', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
mockedRpc.call.mockResolvedValue({ backups: [] })
mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null })
pushMock.mockResolvedValue(undefined)
})
it('renders without errors', () => {
const wrapper = mountSettings()
expect(wrapper.exists()).toBe(true)
})
it('displays the Account section heading', () => {
const wrapper = mountSettings()
const heading = wrapper.find('h2')
expect(heading.exists()).toBe(true)
expect(heading.text()).toBe('Account')
})
it('displays the Account section with server name and version', () => {
const wrapper = mountSettings()
const html = wrapper.html()
// Account section heading
const sectionHeadings = wrapper.findAll('h2')
const accountHeading = sectionHeadings.find((h) => h.text() === 'Account')
expect(accountHeading).toBeDefined()
// Server name rendered
expect(html).toContain('Test Node')
// Version rendered
expect(html).toContain('0.1.0')
})
it('displays the version from server info', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('0.1.0')
expect(html).toContain('Version')
})
it('displays the Interface Mode section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode')
expect(modeHeading).toBeDefined()
})
it('displays the Claude Authentication section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication')
expect(claudeHeading).toBeDefined()
})
it('displays the AI Data Access section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access')
expect(aiHeading).toBeDefined()
})
it('displays the System Updates section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates')
expect(updatesHeading).toBeDefined()
})
it('displays the Backup & Restore section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup'))
expect(backupHeading).toBeDefined()
})
it('displays the Network section', () => {
const wrapper = mountSettings()
const sectionHeadings = wrapper.findAll('h2')
const networkHeading = sectionHeadings.find((h) => h.text() === 'Network')
expect(networkHeading).toBeDefined()
})
it('displays a Logout button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
expect(logoutButton!.exists()).toBe(true)
})
it('logout button triggers store logout and navigates to login', async () => {
const wrapper = mountSettings()
const store = useAppStore()
const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue()
const buttons = wrapper.findAll('button')
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
expect(logoutButton).toBeDefined()
await logoutButton!.trigger('click')
// Allow async handlers to settle
await vi.dynamicImportSettled()
expect(logoutSpy).toHaveBeenCalled()
expect(pushMock).toHaveBeenCalledWith('/login')
})
it('displays a Change Password button', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const changePasswordButton = buttons.find((b) => b.text().includes('Change Password'))
expect(changePasswordButton).toBeDefined()
expect(changePasswordButton!.exists()).toBe(true)
})
it('displays Two-Factor Authentication section with status', () => {
const wrapper = mountSettings()
const html = wrapper.html()
expect(html).toContain('Two-Factor Authentication')
})
it('shows Enable 2FA button when TOTP is not enabled', () => {
const wrapper = mountSettings()
const buttons = wrapper.findAll('button')
const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA'))
expect(enable2faButton).toBeDefined()
})
it('displays session status as currently logged in', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Currently logged in')
})
it('shows server name from the store', () => {
const wrapper = mountSettings()
expect(wrapper.html()).toContain('Server Name')
expect(wrapper.html()).toContain('Test Node')
})
it('defaults version to 0.0.0 when server info has no version', () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useAppStore()
store.$patch({
isAuthenticated: true,
data: {
'server-info': {
id: 'test',
version: '',
name: null,
pubkey: '',
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
'lan-address': null,
'tor-address': null,
unread: 0,
'wifi-ssids': [],
},
'package-data': {},
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
},
})
const wrapper = shallowMount(Settings, {
global: {
plugins: [pinia, i18n],
stubs: {
Teleport: true,
RouterLink: defineComponent({
name: 'RouterLink',
props: { to: { type: String, default: '' } },
setup(_, { slots }) {
return () => h('a', {}, slots.default?.())
},
}),
},
},
})
// When version is empty string, computed returns '0.0.0' from the fallback
const html = wrapper.html()
expect(html).toContain('0.0.0')
})
it('calls totpStatus on mount to check 2FA state', async () => {
mountSettings()
// onMounted calls loadTotpStatus which calls rpcClient.totpStatus
expect(mockedRpc.totpStatus).toHaveBeenCalled()
})
it('calls backup.list on mount to load backups', async () => {
mountSettings()
// onMounted calls loadBackups which calls rpcClient.call with backup.list
expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' })
it('renders AccountSection and SystemSection', () => {
setActivePinia(createPinia())
const wrapper = shallowMount(Settings)
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
})
})

View File

@ -869,6 +869,13 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
animation: dashboard-glitch-scan 5s ease-out infinite;
}
/* Pause dashboard glitch animations during tab switch (backdrop-filter fix) */
html.tab-hidden .dashboard-glitch-1,
html.tab-hidden .dashboard-glitch-2,
html.tab-hidden .dashboard-glitch-scan {
animation-play-state: paused !important;
}
@keyframes dashboard-glitch-shift {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
82.1% { opacity: 0.28; }

464
scripts/run-post-install-tests.sh Executable file
View File

@ -0,0 +1,464 @@
#!/usr/bin/env bash
# Post-install + onboarding + container lifecycle E2E tests.
# Run on an installed Archipelago node (SSH or local).
#
# Usage: bash run-post-install-tests.sh [password]
# password defaults to "testpass123!" for fresh installs
#
# Tests:
# Phase 1: Install verification (services, files, logs)
# Phase 2: Onboarding (password setup, auth flow)
# Phase 3: Container lifecycle (install 3 apps, start/stop/health)
set -u
PASSWORD="${1:-testpass123!}"
BASE="http://127.0.0.1:5678"
JAR="/tmp/e2e-cookies.txt"
rm -f "$JAR"
PC=0; FC=0; SC=0
pass() { PC=$((PC + 1)); printf "\033[32m ✓ %s\033[0m\n" "$1"; }
fail() { FC=$((FC + 1)); printf "\033[31m ✗ %s — %s\033[0m\n" "$1" "${2:-}"; }
skip() { SC=$((SC + 1)); printf "\033[33m ⊘ %s\033[0m\n" "$1"; }
section() { printf "\n\033[1m━━━ %s ━━━\033[0m\n" "$1"; }
rpc() {
local method="$1"
local params="${2:-"{}"}"
curl -s -b "$JAR" -c "$JAR" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" \
"${BASE}/rpc/v1" 2>/dev/null
}
rpc_ok() {
local resp="$1"
[ -z "$resp" ] && return 1
echo "$resp" | grep -q '"error":null' && return 0
echo "$resp" | grep -q '"error"' && return 1
return 0
}
rpc_result() {
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('result','')))" 2>/dev/null
}
wait_for_server() {
local max_wait=60
local waited=0
while [ $waited -lt $max_wait ]; do
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
return 0
fi
sleep 2
waited=$((waited + 2))
done
return 1
}
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ Archipelago Post-Install E2E Test Suite ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
echo "Target: ${BASE}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
# ═══════════════════════════════════════════
# PHASE 1: Install Verification
# ═══════════════════════════════════════════
section "Phase 1: Install Verification"
# 1.1 — Critical files exist
for f in /usr/local/bin/archipelago \
/opt/archipelago/web-ui/index.html \
/etc/nginx/sites-available/archipelago \
/etc/archipelago/ssl/archipelago.crt \
/opt/archipelago/scripts/image-versions.sh; do
if [ -f "$f" ]; then
pass "File exists: $f"
else
fail "File missing" "$f"
fi
done
# 1.2 — Critical services active
for svc in archipelago nginx; do
if systemctl is-active "$svc" >/dev/null 2>&1; then
pass "Service active: $svc"
else
fail "Service not active" "$svc"
fi
done
# 1.3 — Services enabled
for svc in archipelago nginx archipelago-load-images archipelago-first-boot-containers; do
if systemctl is-enabled "$svc" >/dev/null 2>&1; then
pass "Service enabled: $svc"
else
fail "Service not enabled" "$svc"
fi
done
# 1.4 — Podman available for archipelago user
if runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman --version' >/dev/null 2>&1; then
pass "Podman available (rootless, archipelago user)"
else
fail "Podman not available" "rootless podman for archipelago user"
fi
# 1.5 — Linger enabled
if [ -f /var/lib/systemd/linger/archipelago ]; then
pass "Linger enabled for archipelago"
else
fail "Linger not enabled" "/var/lib/systemd/linger/archipelago missing"
fi
# 1.6 — Backend not in dev mode
if systemctl cat archipelago 2>/dev/null | grep -q 'DEV_MODE=true'; then
fail "DEV_MODE enabled" "ARCHIPELAGO_DEV_MODE=true found in service file"
else
pass "DEV_MODE disabled (production mode)"
fi
# 1.7 — Backend running as correct user
SVC_USER=$(systemctl show -p User archipelago 2>/dev/null | cut -d= -f2)
if [ "$SVC_USER" = "archipelago" ]; then
pass "Backend runs as user: archipelago"
elif [ "$SVC_USER" = "root" ]; then
fail "Backend runs as root" "Should be User=archipelago"
else
skip "Cannot determine backend user ($SVC_USER)"
fi
# 1.8 — Health endpoint responds
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
pass "Health endpoint responds"
else
fail "Health endpoint" "No response from ${BASE}/health"
fi
# 1.9 — Web UI loads via nginx
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://localhost/" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
pass "Web UI loads via nginx (HTTPS)"
else
fail "Web UI not accessible" "HTTPS returned $HTTP_CODE"
fi
# 1.10 — Nginx config test
if nginx -t 2>/dev/null; then
pass "Nginx config valid"
else
fail "Nginx config" "nginx -t failed"
fi
# ═══════════════════════════════════════════
# PHASE 2: Onboarding & Auth
# ═══════════════════════════════════════════
section "Phase 2: Onboarding & Auth"
# Wait for server
if ! wait_for_server; then
fail "Server not ready" "Timed out after 60s"
section "Results"
echo " Passed: $PC Failed: $FC Skipped: $SC"
exit 1
fi
# 2.1 — Check setup status (should be false on fresh install)
SETUP_RESP=$(rpc "auth.isSetup")
if rpc_ok "$SETUP_RESP"; then
IS_SETUP=$(echo "$SETUP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',False))" 2>/dev/null)
if [ "$IS_SETUP" = "True" ] || [ "$IS_SETUP" = "true" ]; then
pass "auth.isSetup returns true (user exists)"
# Already set up — just login
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$LOGIN"; then
pass "auth.login (existing user)"
else
# Try default dev password
LOGIN=$(rpc "auth.login" '{"password":"password123"}')
if rpc_ok "$LOGIN"; then
pass "auth.login (dev password)"
PASSWORD="password123"
else
fail "auth.login" "Cannot authenticate"
fi
fi
else
pass "auth.isSetup returns false (fresh install)"
# 2.2 — Set up password
SETUP=$(rpc "auth.setup" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$SETUP"; then
pass "auth.setup (password created)"
else
fail "auth.setup" "$SETUP"
fi
# 2.3 — Login with new password
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$LOGIN"; then
pass "auth.login (new password)"
else
fail "auth.login" "$LOGIN"
fi
fi
else
fail "auth.isSetup" "$SETUP_RESP"
fi
# 2.4 — Onboarding status
OB_RESP=$(rpc "auth.isOnboardingComplete")
if rpc_ok "$OB_RESP"; then
pass "auth.isOnboardingComplete responds"
else
fail "auth.isOnboardingComplete" "$OB_RESP"
fi
# 2.5 — Node DID available
DID_RESP=$(rpc "node.did")
if rpc_ok "$DID_RESP"; then
pass "node.did (DID generated)"
else
fail "node.did" "$DID_RESP"
fi
# 2.6 — Server info
INFO_RESP=$(rpc "server.info")
if rpc_ok "$INFO_RESP"; then
pass "server.info responds"
else
# Try alternate method name
INFO_RESP=$(rpc "system.info")
if rpc_ok "$INFO_RESP"; then
pass "system.info responds"
else
skip "server.info / system.info (may not exist)"
fi
fi
# 2.7 — Mark onboarding complete
OB_COMPLETE=$(rpc "auth.onboardingComplete")
if rpc_ok "$OB_COMPLETE"; then
pass "auth.onboardingComplete"
else
skip "auth.onboardingComplete (may already be done)"
fi
# ═══════════════════════════════════════════
# PHASE 3: Container Lifecycle
# ═══════════════════════════════════════════
section "Phase 3: Container Lifecycle"
# Source image versions for dockerImage URLs
source /opt/archipelago/scripts/image-versions.sh 2>/dev/null || true
# Test with 3 lightweight standalone containers
# package.install expects: {"id": "app_id", "dockerImage": "registry/image:tag"}
# container-start/stop/status expect: {"app_id": "name"}
declare -a APPS=("filebrowser" "searxng" "grafana")
declare -a IMAGES=("${FILEBROWSER_IMAGE:-}" "${SEARXNG_IMAGE:-}" "${GRAFANA_IMAGE:-}")
# 3.1 — List containers (baseline)
LIST_RESP=$(rpc "container-list")
if rpc_ok "$LIST_RESP"; then
pass "container-list (baseline)"
else
fail "container-list" "$LIST_RESP"
fi
for i in 0 1 2; do
APP="${APPS[$i]}"
IMAGE="${IMAGES[$i]}"
section "Container: $APP"
if [ -z "$IMAGE" ]; then
fail "$APP — image variable empty" "image-versions.sh missing or incomplete"
continue
fi
# 3.2 — Install container via package.install RPC
# Check if already exists first
EXISTING=$(rpc "container-list")
if echo "$EXISTING" | grep -q "\"$APP\""; then
pass "$APP already installed (skipping install)"
else
INSTALL_RESP=$(rpc "package.install" "{\"id\":\"$APP\",\"dockerImage\":\"$IMAGE\"}")
if rpc_ok "$INSTALL_RESP"; then
pass "$APP installed"
else
ERR_MSG=$(echo "$INSTALL_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('error',{}); print(e.get('message','unknown') if isinstance(e,dict) else str(e))" 2>/dev/null)
fail "$APP install" "$ERR_MSG"
continue
fi
fi
# Wait for container to start (pull + create + start)
echo " ... waiting for $APP to start"
for attempt in $(seq 1 15); do
sleep 2
STATUS_RESP=$(rpc "container-list")
if echo "$STATUS_RESP" | grep -q "\"$APP\"" && echo "$STATUS_RESP" | grep -q '"running"'; then
break
fi
done
# 3.3 — Verify running
LIST_NOW=$(rpc "container-list")
if echo "$LIST_NOW" | grep -q "\"$APP\""; then
if echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null | grep -q "running"; then
pass "$APP running after install"
else
fail "$APP not running" "Check container-list output"
fi
else
fail "$APP not in container list" ""
continue
fi
# 3.4 — Stop container
STOP_RESP=$(rpc "container-stop" "{\"app_id\":\"$APP\"}")
if rpc_ok "$STOP_RESP"; then
pass "$APP stopped"
else
fail "$APP stop" "$STOP_RESP"
fi
sleep 3
# 3.5 — Verify stopped
LIST_NOW=$(rpc "container-list")
STATE=$(echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null)
if [ "$STATE" = "exited" ] || [ "$STATE" = "stopped" ]; then
pass "$APP confirmed stopped"
else
fail "$APP not stopped" "State: $STATE"
fi
# 3.6 — Restart container
START_RESP=$(rpc "container-start" "{\"app_id\":\"$APP\"}")
if rpc_ok "$START_RESP"; then
pass "$APP restarted"
else
fail "$APP restart" "$START_RESP"
fi
sleep 5
# 3.7 — Verify running again
LIST_NOW=$(rpc "container-list")
STATE=$(echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null)
if [ "$STATE" = "running" ]; then
pass "$APP running after restart"
else
fail "$APP not running after restart" "State: $STATE"
fi
# 3.8 — Health check
HEALTH_RESP=$(rpc "container-health" "{\"app_id\":\"$APP\"}")
if rpc_ok "$HEALTH_RESP"; then
pass "$APP health responds"
else
skip "$APP health (may need warm-up time)"
fi
done
# 3.9 — Final container list (should show all 3)
LIST_RESP=$(rpc "container-list")
if rpc_ok "$LIST_RESP"; then
COUNT=$(echo "$LIST_RESP" | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',[]); print(len(r) if isinstance(r,list) else 0)" 2>/dev/null)
if [ "${COUNT:-0}" -ge 3 ]; then
pass "container-list shows $COUNT containers (>= 3)"
else
fail "container-list" "Only $COUNT containers (expected >= 3)"
fi
else
fail "container-list (final)" "$LIST_RESP"
fi
# ═══════════════════════════════════════════
# PHASE 4: Log Verification
# ═══════════════════════════════════════════
section "Phase 4: Log Verification"
# 4.1 — First-boot log exists and completed
if [ -f /var/log/archipelago-first-boot.log ]; then
if grep -q "first-boot complete" /var/log/archipelago-first-boot.log 2>/dev/null; then
pass "First-boot log: completed"
else
fail "First-boot log" "Did not complete — check /var/log/archipelago-first-boot.log"
fi
else
fail "First-boot log" "/var/log/archipelago-first-boot.log missing"
fi
# 4.2 — Diagnostics log exists
if [ -f /var/log/archipelago-first-boot-diag.log ]; then
pass "Diagnostics log exists"
else
skip "Diagnostics log (/var/log/archipelago-first-boot-diag.log)"
fi
# 4.3 — No critical errors in backend journal
CRIT_ERRORS=$(journalctl -u archipelago --no-pager -p err -b 2>/dev/null | grep -v "Failed to read LND\|Failed to query getblockchain\|Cannot connect to Podman" | head -5)
if [ -z "$CRIT_ERRORS" ]; then
pass "No unexpected backend errors in journal"
else
fail "Backend errors in journal" "$(echo "$CRIT_ERRORS" | head -1)"
fi
# 4.4 — image-versions.sh is accessible
if [ -f /opt/archipelago/scripts/image-versions.sh ]; then
if source /opt/archipelago/scripts/image-versions.sh 2>/dev/null && [ -n "$FILEBROWSER_IMAGE" ]; then
pass "image-versions.sh loads correctly"
else
fail "image-versions.sh" "Cannot source or FILEBROWSER_IMAGE empty"
fi
else
fail "image-versions.sh" "Not found at /opt/archipelago/scripts/"
fi
# ═══════════════════════════════════════════
# Results
# ═══════════════════════════════════════════
section "Results"
TOTAL=$((PC + FC + SC))
echo ""
printf " \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m \033[33mSkipped: %d\033[0m Total: %d\n" "$PC" "$FC" "$SC" "$TOTAL"
echo ""
if [ "$FC" -gt 0 ]; then
echo " ❌ SOME TESTS FAILED"
exit 1
else
echo " ✅ ALL TESTS PASSED"
exit 0
fi