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:
parent
bf14f9e5ad
commit
7cd4d90ed8
@ -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)
|
||||
|
||||
72
.gitea/workflows/post-install-tests.yml
Normal file
72
.gitea/workflows/post-install-tests.yml
Normal 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
|
||||
@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
# 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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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('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')
|
||||
})
|
||||
})
|
||||
|
||||
it('finds inner focusable elements within containers', () => {
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-controller-container', '')
|
||||
container.tabIndex = 0
|
||||
// ─── Zone Detection ─────────────────────────────────────────────
|
||||
|
||||
const btn1 = document.createElement('button')
|
||||
btn1.textContent = 'Action 1'
|
||||
const btn2 = document.createElement('button')
|
||||
btn2.textContent = 'Action 2'
|
||||
describe('useControllerNav - zones', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
container.appendChild(btn1)
|
||||
container.appendChild(btn2)
|
||||
document.body.appendChild(container)
|
||||
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()
|
||||
})
|
||||
|
||||
const inner = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('button:not([disabled])')
|
||||
).filter(el => el !== container)
|
||||
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)
|
||||
})
|
||||
|
||||
document.body.removeChild(container)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useControllerNav - gamepad detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// ─── 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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
464
scripts/run-post-install-tests.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user