diff --git a/.gitea/workflows/build-iso.yml b/.gitea/workflows/build-iso.yml index cf1e4819..63c3a6ad 100644 --- a/.gitea/workflows/build-iso.yml +++ b/.gitea/workflows/build-iso.yml @@ -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) diff --git a/.gitea/workflows/post-install-tests.yml b/.gitea/workflows/post-install-tests.yml new file mode 100644 index 00000000..7c5c4c86 --- /dev/null +++ b/.gitea/workflows/post-install-tests.yml @@ -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 diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 29f98e8c..f62a857f 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -97,7 +97,11 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec { "--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![], } } diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 8bdab97f..95e97451 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -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 diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 52775363..85e54491 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -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(() => { + // 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('no-backdrop') + document.documentElement.classList.remove('tab-hidden') }) - }) + }, 50) } } diff --git a/neode-ui/src/api/__tests__/filebrowser-client.test.ts b/neode-ui/src/api/__tests__/filebrowser-client.test.ts index 98708e3f..c220fa51 100644 --- a/neode-ui/src/api/__tests__/filebrowser-client.test.ts +++ b/neode-ui/src/api/__tests__/filebrowser-client.test.ts @@ -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)) diff --git a/neode-ui/src/composables/__tests__/useControllerNav.test.ts b/neode-ui/src/composables/__tests__/useControllerNav.test.ts index 56be2e40..1f0fa8f4 100644 --- a/neode-ui/src/composables/__tests__/useControllerNav.test.ts +++ b/neode-ui/src/composables/__tests__/useControllerNav.test.ts @@ -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', () => { - 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) +// ─── Nav Key Classification ───────────────────────────────────── + +describe('useControllerNav - nav keys', () => { + const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] + + 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 = ` +
+ + Link + + +
+ ` + 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('button:not([disabled])') - ).filter(el => !el.hasAttribute('data-controller-ignore')) - + it('finds elements with tabindex as focusable', () => { + document.body.innerHTML = ` +
Container
+
Hidden
+
Not focusable
+ ` + 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 = ` +
Card 1
+
Card 2
+
Regular 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('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 = ` + + +
+ ` + const all = Array.from(document.querySelectorAll('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 = ` +
Home
+
+ ` + 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 = ` +
Nav
+
+ ` + 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 = ` +
+ + +
+ ` + 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 = ` +
+ + +
+ ` + const container = document.querySelector('[data-controller-container]')! + const inner = Array.from(container.querySelectorAll('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 = ` +
+ +
+ + ` + 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 = ` +
+ +
+ ` + 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 = ` +
+ +
+ ` + 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 = ` +
+ Home + Apps + Cloud + +
+ ` + 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 = ` +
+ Home + Apps +
+
+ +
+ ` + 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 = ` +
+
Card 1
+
Card 2
+
+ ` + const mainZone = document.querySelector('[data-controller-zone="main"]') + const firstContainer = mainZone?.querySelector('[data-controller-container]') + expect(firstContainer?.id).toBe('first') + }) + + it('does not auto-focus when input is active', () => { + document.body.innerHTML = ` + +
+
Card
+
+ ` + 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 = ` +
+ + +
+ ` + 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 = ` +
+ + +
+ ` + 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 = ` +
+
Card 1
+
+ ` + 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 = ` +
+
App 1
+
App 2
+
App 3
+
App 4
+
App 5
+
App 6
+
+ ` + 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 = ` +
+
My Apps
+
Wallet
+
System
+
+ ` + const containers = document.querySelectorAll('[data-controller-container]') + expect(containers.length).toBe(3) }) }) diff --git a/neode-ui/src/composables/__tests__/useMessageToast.test.ts b/neode-ui/src/composables/__tests__/useMessageToast.test.ts index 93a2ed3d..21e8fe69 100644 --- a/neode-ui/src/composables/__tests__/useMessageToast.test.ts +++ b/neode-ui/src/composables/__tests__/useMessageToast.test.ts @@ -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 () => { diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index 60842524..3e011cbe 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -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('[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('[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(() => { diff --git a/neode-ui/src/views/ContainerApps.vue b/neode-ui/src/views/ContainerApps.vue index fcf33c7d..4337f2a5 100644 --- a/neode-ui/src/views/ContainerApps.vue +++ b/neode-ui/src/views/ContainerApps.vue @@ -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 @@