refactor: update dependencies and remove unused code

- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`.
- Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27.
- Removed the `backup.rs` file as it is no longer needed.
- Introduced tests for configuration and credential management.
- Enhanced the `identity` module to generate W3C compliant DID documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 00:19:30 +00:00
co-authored by Claude Opus 4.6
parent 2a867b32a8
commit 6fee6befed
347 changed files with 18703 additions and 46785 deletions
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '../aiPermissions'
const STORAGE_KEY = 'archipelago-ai-permissions'
describe('useAIPermissionsStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.clearAllMocks()
})
it('starts with empty permissions when no localStorage', () => {
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(0)
expect(store.noneEnabled).toBe(true)
expect(store.allEnabled).toBe(false)
})
it('loads valid categories from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'system']))
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.isEnabled('apps')).toBe(true)
expect(store.isEnabled('system')).toBe(true)
expect(store.enabled.size).toBe(2)
})
it('filters invalid categories from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(['apps', 'invalid-category', 'system']))
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(2)
expect(store.isEnabled('apps')).toBe(true)
expect(store.isEnabled('system')).toBe(true)
})
it('handles corrupt localStorage gracefully', () => {
localStorage.setItem(STORAGE_KEY, 'not-valid-json{')
setActivePinia(createPinia())
const store = useAIPermissionsStore()
expect(store.enabled.size).toBe(0)
})
it('toggle adds a category', () => {
const store = useAIPermissionsStore()
store.toggle('bitcoin')
expect(store.isEnabled('bitcoin')).toBe(true)
})
it('toggle removes an enabled category', () => {
const store = useAIPermissionsStore()
store.toggle('bitcoin')
store.toggle('bitcoin')
expect(store.isEnabled('bitcoin')).toBe(false)
})
it('toggle persists to localStorage', () => {
const store = useAIPermissionsStore()
store.toggle('apps')
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
expect(stored).toContain('apps')
})
it('enableAll enables all categories', () => {
const store = useAIPermissionsStore()
store.enableAll()
expect(store.allEnabled).toBe(true)
expect(store.enabled.size).toBe(AI_PERMISSION_CATEGORIES.length)
for (const cat of AI_PERMISSION_CATEGORIES) {
expect(store.isEnabled(cat.id)).toBe(true)
}
})
it('disableAll disables all categories', () => {
const store = useAIPermissionsStore()
store.enableAll()
store.disableAll()
expect(store.noneEnabled).toBe(true)
expect(store.enabled.size).toBe(0)
})
it('enabledCategories returns array of enabled IDs', () => {
const store = useAIPermissionsStore()
store.toggle('apps')
store.toggle('network')
expect(store.enabledCategories).toContain('apps')
expect(store.enabledCategories).toContain('network')
expect(store.enabledCategories.length).toBe(2)
})
it('AI_PERMISSION_CATEGORIES has 10 categories', () => {
expect(AI_PERMISSION_CATEGORIES.length).toBe(10)
})
it('all categories have required fields', () => {
for (const cat of AI_PERMISSION_CATEGORIES) {
expect(cat.id).toBeTruthy()
expect(cat.label).toBeTruthy()
expect(cat.description).toBeTruthy()
expect(cat.icon).toBeTruthy()
expect(cat.group).toBeTruthy()
}
})
})
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppLauncherStore } from '../appLauncher'
// Mock window.open for new-tab tests
const mockWindowOpen = vi.fn()
vi.stubGlobal('open', mockWindowOpen)
describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
})
it('starts closed with empty state', () => {
const store = useAppLauncherStore()
expect(store.isOpen).toBe(false)
expect(store.url).toBe('')
expect(store.title).toBe('')
})
it('opens an app in the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool' })
expect(store.isOpen).toBe(true)
expect(store.url).toBe('http://192.168.1.228:8080')
expect(store.title).toBe('Mempool')
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('opens BTCPay (port 23000) in a new tab due to X-Frame-Options', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:23000',
'_blank',
'noopener,noreferrer',
)
})
it('opens Home Assistant (port 8123) in a new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalled()
})
it('opens Grafana (port 3000) in a new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalled()
})
it('opens in new tab when openInNewTab flag is set', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8080',
'_blank',
'noopener,noreferrer',
)
})
it('rewrites URL to proxy path on HTTPS for same-host apps', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
expect(store.isOpen).toBe(true)
expect(store.url).toBe('https://192.168.1.228/app/filebrowser/')
})
it('does not rewrite URL on HTTP (no mixed content)', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
expect(store.url).toBe('http://192.168.1.228:8083')
})
it('does not rewrite URL for different hosts', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.100:8083', title: 'Remote FileBrowser' })
// Different host — no proxy rewriting
expect(store.url).toBe('http://192.168.1.100:8083')
})
it('close resets state', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool' })
store.close()
expect(store.isOpen).toBe(false)
expect(store.url).toBe('')
expect(store.title).toBe('')
})
it('close restores focus to previous element', async () => {
vi.useFakeTimers()
const store = useAppLauncherStore()
const mockButton = { focus: vi.fn() } as unknown as HTMLElement
Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true })
store.open({ url: 'http://192.168.1.228:8080', title: 'Mempool' })
store.close()
expect(store.isOpen).toBe(false)
expect(store.url).toBe('')
// requestAnimationFrame fires the focus restore callback
vi.runAllTimers()
vi.useRealTimers()
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
import { useCLIStore } from '../cli'
import { playNavSound } from '@/composables/useNavSounds'
const mockedPlayNavSound = vi.mocked(playNavSound)
describe('useCLIStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts closed', () => {
const store = useCLIStore()
expect(store.isOpen).toBe(false)
})
it('open sets isOpen to true and plays sound', () => {
const store = useCLIStore()
store.open()
expect(store.isOpen).toBe(true)
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
})
it('close sets isOpen to false without sound', () => {
const store = useCLIStore()
store.open()
vi.clearAllMocks()
store.close()
expect(store.isOpen).toBe(false)
expect(mockedPlayNavSound).not.toHaveBeenCalled()
})
it('toggle opens and plays sound when closed', () => {
const store = useCLIStore()
store.toggle()
expect(store.isOpen).toBe(true)
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
})
it('toggle closes without sound when open', () => {
const store = useCLIStore()
store.open()
vi.clearAllMocks()
store.toggle()
expect(store.isOpen).toBe(false)
expect(mockedPlayNavSound).not.toHaveBeenCalled()
})
it('multiple toggles alternate state', () => {
const store = useCLIStore()
store.toggle()
expect(store.isOpen).toBe(true)
store.toggle()
expect(store.isOpen).toBe(false)
store.toggle()
expect(store.isOpen).toBe(true)
})
})
+233
View File
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/api/filebrowser-client', () => ({
fileBrowserClient: {
login: vi.fn(),
listDirectory: vi.fn(),
upload: vi.fn(),
deleteItem: vi.fn(),
downloadUrl: vi.fn(),
createFolder: vi.fn(),
},
}))
import { useCloudStore } from '../cloud'
import { fileBrowserClient } from '@/api/filebrowser-client'
const mockedClient = vi.mocked(fileBrowserClient)
const mockItems = [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-02', isDir: false, type: '', extension: 'md' },
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-03', isDir: false, type: '', extension: 'zip' },
]
describe('useCloudStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts with default state', () => {
const store = useCloudStore()
expect(store.currentPath).toBe('/')
expect(store.items).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
expect(store.authenticated).toBe(false)
})
it('init authenticates with filebrowser', async () => {
mockedClient.login.mockResolvedValue(true)
const store = useCloudStore()
const result = await store.init()
expect(result).toBe(true)
expect(store.authenticated).toBe(true)
expect(mockedClient.login).toHaveBeenCalledOnce()
})
it('init returns false on auth failure', async () => {
mockedClient.login.mockResolvedValue(false)
const store = useCloudStore()
const result = await store.init()
expect(result).toBe(false)
expect(store.authenticated).toBe(false)
})
it('init skips login if already authenticated', async () => {
mockedClient.login.mockResolvedValue(true)
const store = useCloudStore()
await store.init()
await store.init()
expect(mockedClient.login).toHaveBeenCalledOnce()
})
it('navigate loads items and updates path', async () => {
mockedClient.login.mockResolvedValue(true)
mockedClient.listDirectory.mockResolvedValue(mockItems)
const store = useCloudStore()
store.authenticated = true
await store.navigate('/photos')
expect(store.items).toEqual(mockItems)
expect(store.currentPath).toBe('/photos')
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('navigate authenticates if not authenticated', async () => {
mockedClient.login.mockResolvedValue(true)
mockedClient.listDirectory.mockResolvedValue(mockItems)
const store = useCloudStore()
await store.navigate('/')
expect(mockedClient.login).toHaveBeenCalled()
expect(store.items).toEqual(mockItems)
})
it('navigate sets error on auth failure', async () => {
mockedClient.login.mockResolvedValue(false)
const store = useCloudStore()
await store.navigate('/')
expect(store.error).toBe('Failed to authenticate with File Browser')
expect(store.loading).toBe(false)
})
it('navigate falls back to creating directory on list failure', async () => {
const store = useCloudStore()
store.authenticated = true
// First listDirectory fails, then createFolder succeeds, then retry succeeds
mockedClient.listDirectory
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce([])
mockedClient.createFolder.mockResolvedValue(undefined)
await store.navigate('/new-folder')
expect(mockedClient.createFolder).toHaveBeenCalledWith('/', 'new-folder')
expect(store.currentPath).toBe('/new-folder')
})
it('navigate falls back to root when directory creation also fails', async () => {
const store = useCloudStore()
store.authenticated = true
// Call 1: listDirectory('/deep/nested') rejects
// Call 2: listDirectory('/') in the fallback catch resolves
mockedClient.listDirectory
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce(mockItems)
mockedClient.createFolder.mockRejectedValueOnce(new Error('Create failed'))
await store.navigate('/deep/nested')
expect(store.currentPath).toBe('/')
expect(store.items).toEqual(mockItems)
})
it('navigate sets error when root listing fails', async () => {
const store = useCloudStore()
store.authenticated = true
mockedClient.listDirectory.mockRejectedValueOnce(new Error('Server error'))
await store.navigate('/')
expect(store.error).toBe('Failed to list root directory')
})
it('breadcrumbs computes from path', () => {
const store = useCloudStore()
store.currentPath = '/photos/vacation/2026'
expect(store.breadcrumbs).toEqual([
{ name: 'Home', path: '/' },
{ name: 'photos', path: '/photos' },
{ name: 'vacation', path: '/photos/vacation' },
{ name: '2026', path: '/photos/vacation/2026' },
])
})
it('breadcrumbs at root only shows Home', () => {
const store = useCloudStore()
expect(store.breadcrumbs).toEqual([{ name: 'Home', path: '/' }])
})
it('sortedItems puts directories first, sorted alphabetically', () => {
const store = useCloudStore()
store.items = [
{ name: 'readme.md', path: '/readme.md', size: 256, modified: '2026-01-01', isDir: false, type: '', extension: 'md' },
{ name: 'docs', path: '/docs', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'archive.zip', path: '/archive.zip', size: 4096, modified: '2026-01-01', isDir: false, type: '', extension: 'zip' },
{ name: 'assets', path: '/assets', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
]
const sorted = store.sortedItems
expect(sorted.map((i) => i.name)).toEqual(['assets', 'docs', 'archive.zip', 'readme.md'])
})
it('uploadFile uploads and refreshes', async () => {
const store = useCloudStore()
store.authenticated = true
store.currentPath = '/uploads'
mockedClient.upload.mockResolvedValue(undefined)
mockedClient.listDirectory.mockResolvedValue([])
const file = new File(['test'], 'test.txt')
await store.uploadFile(file)
expect(mockedClient.upload).toHaveBeenCalledWith('/uploads', file)
expect(mockedClient.listDirectory).toHaveBeenCalled()
})
it('deleteItem deletes and refreshes', async () => {
const store = useCloudStore()
store.authenticated = true
store.currentPath = '/'
mockedClient.deleteItem.mockResolvedValue(undefined)
mockedClient.listDirectory.mockResolvedValue([])
await store.deleteItem('/old-file.txt')
expect(mockedClient.deleteItem).toHaveBeenCalledWith('/old-file.txt')
expect(mockedClient.listDirectory).toHaveBeenCalled()
})
it('downloadUrl delegates to filebrowser client', () => {
mockedClient.downloadUrl.mockReturnValue('http://localhost/api/raw/file.txt?auth=token')
const store = useCloudStore()
const url = store.downloadUrl('/file.txt')
expect(url).toBe('http://localhost/api/raw/file.txt?auth=token')
expect(mockedClient.downloadUrl).toHaveBeenCalledWith('/file.txt')
})
it('reset clears all state', () => {
const store = useCloudStore()
store.currentPath = '/deep/path'
store.items = mockItems
store.loading = true
store.error = 'something'
store.reset()
expect(store.currentPath).toBe('/')
expect(store.items).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useControllerStore } from '../controller'
describe('useControllerStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with default state', () => {
const store = useControllerStore()
expect(store.isActive).toBe(false)
expect(store.gamepadCount).toBe(0)
})
it('setActive sets isActive to true', () => {
const store = useControllerStore()
store.setActive(true)
expect(store.isActive).toBe(true)
})
it('setActive sets isActive to false', () => {
const store = useControllerStore()
store.setActive(true)
store.setActive(false)
expect(store.isActive).toBe(false)
})
it('setGamepadCount updates count and activates when > 0', () => {
const store = useControllerStore()
store.setGamepadCount(2)
expect(store.gamepadCount).toBe(2)
expect(store.isActive).toBe(true)
})
it('setGamepadCount deactivates when count is 0', () => {
const store = useControllerStore()
store.setGamepadCount(1)
expect(store.isActive).toBe(true)
store.setGamepadCount(0)
expect(store.gamepadCount).toBe(0)
expect(store.isActive).toBe(false)
})
it('setActive does not affect gamepadCount', () => {
const store = useControllerStore()
store.setGamepadCount(3)
store.setActive(false)
expect(store.isActive).toBe(false)
expect(store.gamepadCount).toBe(3)
})
})
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock the app store
const mockPackages: Record<string, { state: string }> = {}
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
packages: mockPackages,
}),
}))
// Mock the goals data with a controlled set
vi.mock('@/data/goals', () => ({
GOALS: [
{
id: 'accept-payments',
title: 'Accept Payments',
subtitle: 'Receive Bitcoin and Lightning payments',
icon: 'payments',
category: 'payments',
requiredApps: ['bitcoin-knots', 'lnd'],
steps: [
{ id: 'install-bitcoin', title: 'Install Bitcoin', description: '', appId: 'bitcoin-knots', action: 'install', isAutomatic: true },
{ id: 'install-lnd', title: 'Install LND', description: '', appId: 'lnd', action: 'install', isAutomatic: true },
{ id: 'open-channel', title: 'Open Channel', description: '', action: 'configure', isAutomatic: false },
],
estimatedTime: '~30 min',
difficulty: 'beginner',
},
{
id: 'create-identity',
title: 'Create Identity',
subtitle: 'Sovereign digital identity',
icon: 'identity',
category: 'identity',
requiredApps: [],
steps: [
{ id: 'generate-did', title: 'Generate DID', description: '', action: 'verify', isAutomatic: true },
{ id: 'setup-nostr', title: 'Setup Nostr', description: '', action: 'configure', isAutomatic: false },
],
estimatedTime: '~5 min',
difficulty: 'beginner',
},
{
id: 'store-photos',
title: 'Store Photos',
subtitle: 'Private photo backup',
icon: 'photos',
category: 'storage',
requiredApps: ['immich'],
steps: [
{ id: 'install-immich', title: 'Install Immich', description: '', appId: 'immich', action: 'install', isAutomatic: true },
{ id: 'configure-immich', title: 'Configure', description: '', action: 'configure', isAutomatic: false },
],
estimatedTime: '~15 min',
difficulty: 'beginner',
},
],
}))
import { useGoalStore } from '../goals'
describe('useGoalStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
// Clear mock packages
Object.keys(mockPackages).forEach((k) => delete mockPackages[k])
})
it('starts with empty progress', () => {
const store = useGoalStore()
expect(store.progress).toEqual({})
})
it('loads progress from localStorage', () => {
const savedProgress = {
'accept-payments': {
goalId: 'accept-payments',
status: 'in-progress',
currentStepIndex: 1,
completedSteps: ['install-bitcoin'],
startedAt: 1000,
},
}
localStorage.setItem('archipelago-goal-progress', JSON.stringify(savedProgress))
const store = useGoalStore()
expect(store.progress['accept-payments']).toBeDefined()
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
})
it('handles corrupt localStorage data', () => {
localStorage.setItem('archipelago-goal-progress', 'not-valid-json{{{')
const store = useGoalStore()
expect(store.progress).toEqual({})
})
it('startGoal creates progress entry and saves', () => {
const store = useGoalStore()
store.startGoal('accept-payments')
expect(store.progress['accept-payments']).toBeDefined()
expect(store.progress['accept-payments']!.status).toBe('in-progress')
expect(store.progress['accept-payments']!.currentStepIndex).toBe(0)
expect(store.progress['accept-payments']!.completedSteps).toEqual([])
expect(localStorage.getItem('archipelago-goal-progress')).not.toBeNull()
})
it('completeStep adds step to completedSteps', () => {
const store = useGoalStore()
store.startGoal('accept-payments')
store.completeStep('accept-payments', 'install-bitcoin')
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
})
it('completeStep does not duplicate step IDs', () => {
const store = useGoalStore()
store.startGoal('accept-payments')
store.completeStep('accept-payments', 'install-bitcoin')
store.completeStep('accept-payments', 'install-bitcoin')
expect(store.progress['accept-payments']!.completedSteps.filter((s) => s === 'install-bitcoin')).toHaveLength(1)
})
it('completeStep marks goal completed when all steps done', () => {
const store = useGoalStore()
store.startGoal('accept-payments')
store.completeStep('accept-payments', 'install-bitcoin')
store.completeStep('accept-payments', 'install-lnd')
store.completeStep('accept-payments', 'open-channel')
expect(store.progress['accept-payments']!.status).toBe('completed')
})
it('completeStep is a no-op when goal not started', () => {
const store = useGoalStore()
store.completeStep('accept-payments', 'install-bitcoin')
expect(store.progress['accept-payments']).toBeUndefined()
})
it('resetGoal removes progress entry', () => {
const store = useGoalStore()
store.startGoal('accept-payments')
expect(store.progress['accept-payments']).toBeDefined()
store.resetGoal('accept-payments')
expect(store.progress['accept-payments']).toBeUndefined()
})
describe('getGoalStatus', () => {
it('returns not-started for unknown goal', () => {
const store = useGoalStore()
expect(store.getGoalStatus('nonexistent')).toBe('not-started')
})
it('returns not-started when no apps installed and no progress', () => {
const store = useGoalStore()
expect(store.getGoalStatus('accept-payments')).toBe('not-started')
})
it('returns completed when all required apps are running', () => {
mockPackages['bitcoin-knots'] = { state: 'running' }
mockPackages['lnd'] = { state: 'running' }
const store = useGoalStore()
expect(store.getGoalStatus('accept-payments')).toBe('completed')
})
it('returns in-progress when some required apps are installed', () => {
mockPackages['bitcoin-knots'] = { state: 'running' }
const store = useGoalStore()
expect(store.getGoalStatus('accept-payments')).toBe('in-progress')
})
it('uses manual progress for goals without required apps', () => {
const store = useGoalStore()
// create-identity has no required apps
expect(store.getGoalStatus('create-identity')).toBe('not-started')
store.startGoal('create-identity')
expect(store.getGoalStatus('create-identity')).toBe('in-progress')
})
it('recognizes app aliases (immich-server matches immich)', () => {
mockPackages['immich-server'] = { state: 'running' }
const store = useGoalStore()
expect(store.getGoalStatus('store-photos')).toBe('completed')
})
it('auto-syncs install steps from actual package state', () => {
mockPackages['bitcoin-knots'] = { state: 'stopped' }
const store = useGoalStore()
store.getGoalStatus('accept-payments')
// Should have auto-created progress and marked install-bitcoin as completed
expect(store.progress['accept-payments']).toBeDefined()
expect(store.progress['accept-payments']!.completedSteps).toContain('install-bitcoin')
})
})
it('goalStatuses computes status for all goals', () => {
mockPackages['bitcoin-knots'] = { state: 'running' }
mockPackages['lnd'] = { state: 'running' }
const store = useGoalStore()
const statuses = store.goalStatuses
expect(statuses['accept-payments']).toBe('completed')
expect(statuses['create-identity']).toBe('not-started')
expect(statuses['store-photos']).toBe('not-started')
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useLoginTransitionStore } from '../loginTransition'
describe('useLoginTransitionStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with all flags false', () => {
const store = useLoginTransitionStore()
expect(store.justLoggedIn).toBe(false)
expect(store.pendingWelcomeTyping).toBe(false)
expect(store.startWelcomeTyping).toBe(false)
})
it('setJustLoggedIn updates justLoggedIn', () => {
const store = useLoginTransitionStore()
store.setJustLoggedIn(true)
expect(store.justLoggedIn).toBe(true)
store.setJustLoggedIn(false)
expect(store.justLoggedIn).toBe(false)
})
it('setPendingWelcomeTyping updates pendingWelcomeTyping', () => {
const store = useLoginTransitionStore()
store.setPendingWelcomeTyping(true)
expect(store.pendingWelcomeTyping).toBe(true)
store.setPendingWelcomeTyping(false)
expect(store.pendingWelcomeTyping).toBe(false)
})
it('setStartWelcomeTyping updates startWelcomeTyping', () => {
const store = useLoginTransitionStore()
store.setStartWelcomeTyping(true)
expect(store.startWelcomeTyping).toBe(true)
store.setStartWelcomeTyping(false)
expect(store.startWelcomeTyping).toBe(false)
})
it('flags are independent of each other', () => {
const store = useLoginTransitionStore()
store.setJustLoggedIn(true)
store.setPendingWelcomeTyping(true)
expect(store.startWelcomeTyping).toBe(false)
store.setStartWelcomeTyping(true)
store.setJustLoggedIn(false)
expect(store.pendingWelcomeTyping).toBe(true)
expect(store.startWelcomeTyping).toBe(true)
})
})
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useScreensaverStore } from '../screensaver'
describe('useScreensaverStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts inactive', () => {
const store = useScreensaverStore()
expect(store.isActive).toBe(false)
})
it('activate sets isActive to true', () => {
const store = useScreensaverStore()
store.activate()
expect(store.isActive).toBe(true)
})
it('deactivate sets isActive to false', () => {
const store = useScreensaverStore()
store.activate()
store.deactivate()
expect(store.isActive).toBe(false)
})
it('deactivate starts inactivity timer that activates after 3 minutes', () => {
const store = useScreensaverStore()
store.deactivate()
expect(store.isActive).toBe(false)
vi.advanceTimersByTime(3 * 60 * 1000)
expect(store.isActive).toBe(true)
})
it('resetInactivityTimer restarts the 3-minute countdown', () => {
const store = useScreensaverStore()
store.deactivate()
// Advance 2 minutes
vi.advanceTimersByTime(2 * 60 * 1000)
expect(store.isActive).toBe(false)
// Reset timer
store.resetInactivityTimer()
// Advance another 2 minutes (would have triggered without reset)
vi.advanceTimersByTime(2 * 60 * 1000)
expect(store.isActive).toBe(false)
// Full 3 minutes from reset
vi.advanceTimersByTime(1 * 60 * 1000)
expect(store.isActive).toBe(true)
})
it('clearInactivityTimer prevents activation', () => {
const store = useScreensaverStore()
store.deactivate()
store.clearInactivityTimer()
vi.advanceTimersByTime(5 * 60 * 1000)
expect(store.isActive).toBe(false)
})
it('activate clears any pending timer', () => {
const store = useScreensaverStore()
store.deactivate()
store.activate()
// If timer wasn't cleared, deactivating and waiting would trigger twice
store.deactivate()
vi.advanceTimersByTime(3 * 60 * 1000)
expect(store.isActive).toBe(true)
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock the nav sounds module
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
import { useSpotlightStore } from '../spotlight'
import { playNavSound } from '@/composables/useNavSounds'
const mockedPlayNavSound = vi.mocked(playNavSound)
describe('useSpotlightStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
})
it('starts closed with default state', () => {
const store = useSpotlightStore()
expect(store.isOpen).toBe(false)
expect(store.selectedIndex).toBe(0)
expect(store.recentItems).toEqual([])
})
it('open sets isOpen to true and plays sound', () => {
const store = useSpotlightStore()
store.open()
expect(store.isOpen).toBe(true)
expect(store.selectedIndex).toBe(0)
expect(mockedPlayNavSound).toHaveBeenCalledWith('action')
})
it('close sets isOpen to false and resets index', () => {
const store = useSpotlightStore()
store.open()
store.close()
expect(store.isOpen).toBe(false)
expect(store.selectedIndex).toBe(0)
})
it('toggle opens when closed', () => {
const store = useSpotlightStore()
store.toggle()
expect(store.isOpen).toBe(true)
})
it('toggle closes when open', () => {
const store = useSpotlightStore()
store.open()
store.toggle()
expect(store.isOpen).toBe(false)
})
it('setSelectedIndex updates the selected index', () => {
const store = useSpotlightStore()
store.setSelectedIndex(3)
expect(store.selectedIndex).toBe(3)
})
describe('recent items', () => {
it('addRecentItem adds item with timestamp', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
expect(store.recentItems).toHaveLength(1)
expect(store.recentItems[0]!.id).toBe('home')
expect(store.recentItems[0]!.label).toBe('Home')
expect(store.recentItems[0]!.timestamp).toBeGreaterThan(0)
})
it('addRecentItem deduplicates by id and type', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate' })
store.addRecentItem({ id: 'home', label: 'Home Updated', path: '/dashboard', type: 'navigate' })
expect(store.recentItems).toHaveLength(1)
expect(store.recentItems[0]!.label).toBe('Home Updated')
})
it('addRecentItem keeps different types with same id', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (navigate)', type: 'navigate' })
store.addRecentItem({ id: 'bitcoin', label: 'Bitcoin (action)', type: 'action' })
expect(store.recentItems).toHaveLength(2)
})
it('addRecentItem caps at 8 items', () => {
const store = useSpotlightStore()
for (let i = 0; i < 10; i++) {
store.addRecentItem({ id: `item-${i}`, label: `Item ${i}`, type: 'navigate' })
}
expect(store.recentItems).toHaveLength(8)
// Most recent should be first
expect(store.recentItems[0]!.id).toBe('item-9')
})
it('addRecentItem persists to localStorage', () => {
const store = useSpotlightStore()
store.addRecentItem({ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate' })
const stored = JSON.parse(localStorage.getItem('archipelago-spotlight-recent')!)
expect(stored).toHaveLength(1)
expect(stored[0].id).toBe('apps')
})
it('loadRecentItems reads from localStorage', () => {
const saved = [
{ id: 'home', label: 'Home', path: '/dashboard', type: 'navigate', timestamp: 1000 },
{ id: 'apps', label: 'Apps', path: '/apps', type: 'navigate', timestamp: 2000 },
]
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toHaveLength(2)
expect(store.recentItems[0]!.id).toBe('home')
})
it('loadRecentItems handles corrupt localStorage', () => {
localStorage.setItem('archipelago-spotlight-recent', 'not-json{{{')
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toEqual([])
})
it('loadRecentItems handles empty localStorage', () => {
const store = useSpotlightStore()
store.loadRecentItems()
expect(store.recentItems).toEqual([])
})
it('open calls loadRecentItems', () => {
const saved = [
{ id: 'test', label: 'Test', type: 'navigate', timestamp: 1000 },
]
localStorage.setItem('archipelago-spotlight-recent', JSON.stringify(saved))
const store = useSpotlightStore()
store.open()
expect(store.recentItems).toHaveLength(1)
})
})
describe('help modal', () => {
it('showHelpModal opens the modal with content', () => {
const store = useSpotlightStore()
store.showHelpModal({
title: 'What is Bitcoin?',
content: 'A peer-to-peer electronic cash system.',
relatedPath: '/apps/bitcoin',
})
expect(store.helpModal.show).toBe(true)
expect(store.helpModal.title).toBe('What is Bitcoin?')
expect(store.helpModal.content).toBe('A peer-to-peer electronic cash system.')
expect(store.helpModal.relatedPath).toBe('/apps/bitcoin')
})
it('closeHelpModal closes the modal', () => {
const store = useSpotlightStore()
store.showHelpModal({ title: 'Test', content: 'Content' })
store.closeHelpModal()
expect(store.helpModal.show).toBe(false)
})
})
})
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUIModeStore } from '../uiMode'
describe('useUIModeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('defaults to gamer mode when no stored value', () => {
const store = useUIModeStore()
expect(store.mode).toBe('gamer')
expect(store.isGamer).toBe(true)
expect(store.isEasy).toBe(false)
expect(store.isChat).toBe(false)
})
it('loads stored mode from localStorage', () => {
localStorage.setItem('archipelago-ui-mode', 'easy')
const store = useUIModeStore()
expect(store.mode).toBe('easy')
expect(store.isEasy).toBe(true)
})
it('ignores invalid localStorage values', () => {
localStorage.setItem('archipelago-ui-mode', 'invalid-mode')
const store = useUIModeStore()
expect(store.mode).toBe('gamer')
})
it('setMode updates mode and persists', () => {
const store = useUIModeStore()
store.setMode('easy')
expect(store.mode).toBe('easy')
expect(store.isEasy).toBe(true)
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
})
it('setMode to chat mode', () => {
const store = useUIModeStore()
store.setMode('chat')
expect(store.mode).toBe('chat')
expect(store.isChat).toBe(true)
expect(store.isGamer).toBe(false)
})
it('cycleMode cycles between easy and gamer', () => {
const store = useUIModeStore()
// Start at gamer
expect(store.mode).toBe('gamer')
// Cycle to easy
const next1 = store.cycleMode()
expect(next1).toBe('easy')
expect(store.mode).toBe('easy')
// Cycle back to gamer (wraps after easy since order is [easy, gamer])
const next2 = store.cycleMode()
expect(next2).toBe('gamer')
expect(store.mode).toBe('gamer')
})
it('cycleMode from chat wraps to easy', () => {
const store = useUIModeStore()
store.setMode('chat')
const next = store.cycleMode()
// chat is not in the order array, so idx=-1, next = order[0] = easy
expect(next).toBe('easy')
})
it('syncFromBackend updates mode from backend', () => {
const store = useUIModeStore()
store.syncFromBackend('easy')
expect(store.mode).toBe('easy')
expect(localStorage.getItem('archipelago-ui-mode')).toBe('easy')
})
it('syncFromBackend ignores invalid modes', () => {
const store = useUIModeStore()
store.syncFromBackend('invalid' as 'gamer')
expect(store.mode).toBe('gamer') // unchanged
})
it('syncFromBackend ignores undefined', () => {
const store = useUIModeStore()
store.syncFromBackend(undefined)
expect(store.mode).toBe('gamer') // unchanged
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useWeb5BadgeStore } from '../web5Badge'
// Mock rpcClient
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { rpcClient } from '@/api/rpc-client'
describe('useWeb5BadgeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts with zero pending requests', () => {
const store = useWeb5BadgeStore()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh updates count from API', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
requests: [{ id: '1' }, { id: '2' }, { id: '3' }],
})
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(3)
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'network.list-requests' })
})
it('refresh handles empty requests', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: [] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh handles null requests gracefully', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ requests: null })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(0)
})
it('refresh handles API error gracefully', async () => {
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('Network error'))
const store = useWeb5BadgeStore()
store.pendingRequestCount = 5 // pre-existing value
await store.refresh()
// Should not throw, count stays at pre-existing value (error swallowed)
expect(store.pendingRequestCount).toBe(5)
})
it('refresh updates count on subsequent calls', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({ requests: [{ id: '1' }] })
.mockResolvedValueOnce({ requests: [{ id: '1' }, { id: '2' }] })
const store = useWeb5BadgeStore()
await store.refresh()
expect(store.pendingRequestCount).toBe(1)
await store.refresh()
expect(store.pendingRequestCount).toBe(2)
})
})
+16 -20
View File
@@ -47,7 +47,7 @@ export const useAppStore = defineStore('app', () => {
// Connect WebSocket in background - don't block login flow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
return {}
} catch (err) {
@@ -64,7 +64,7 @@ export const useAppStore = defineStore('app', () => {
localStorage.setItem('neode-auth', 'true')
await initializeData()
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})
}
@@ -72,7 +72,7 @@ export const useAppStore = defineStore('app', () => {
try {
await rpcClient.logout()
} catch (err) {
console.error('Logout error:', err)
if (import.meta.env.DEV) console.error('Logout error:', err)
} finally {
isAuthenticated.value = false
sessionValidated = false
@@ -87,7 +87,7 @@ export const useAppStore = defineStore('app', () => {
async function connectWebSocket(): Promise<void> {
try {
console.log('[Store] Connecting WebSocket...')
if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...')
isReconnecting.value = true
// Don't create multiple subscriptions - check if already subscribed
@@ -96,20 +96,16 @@ export const useAppStore = defineStore('app', () => {
isWsSubscribed = true
// Listen for connection state changes
wsClient.onConnectionStateChange((connected) => {
console.log('[Store] WebSocket connection state changed:', connected)
isConnected.value = connected
if (!connected) {
isReconnecting.value = true
} else {
isReconnecting.value = false
}
wsClient.onConnectionStateChange((state) => {
if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state)
isConnected.value = state === 'connected'
isReconnecting.value = state === 'connecting'
})
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
// Handle mock backend format: {type: 'initial', data: {...}}
if (update?.type === 'initial' && update?.data) {
console.log('[Store] Received initial data from mock backend')
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
data.value = update.data
isConnected.value = true
isReconnecting.value = false
@@ -123,7 +119,7 @@ export const useAppStore = defineStore('app', () => {
// Handle patch updates (both backends)
else if (data.value && update?.patch) {
try {
console.log('[Store] Applying patch at revision', update.rev || 'unknown')
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch)
// Mark as connected once we receive any valid patch
if (!isConnected.value) {
@@ -131,7 +127,7 @@ export const useAppStore = defineStore('app', () => {
isReconnecting.value = false
}
} catch (err) {
console.error('[Store] Failed to apply WebSocket patch:', err)
if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err)
}
}
})
@@ -140,14 +136,14 @@ export const useAppStore = defineStore('app', () => {
// Now connect (or reconnect if already connected)
// Only attempt to connect if not already connected
if (wsClient.isConnected()) {
console.log('[Store] WebSocket already connected')
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
isConnected.value = true
isReconnecting.value = false
return
}
await wsClient.connect()
console.log('[Store] WebSocket connected')
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
// Connection state will be updated via the callback
if (wsClient.isConnected()) {
@@ -156,7 +152,7 @@ export const useAppStore = defineStore('app', () => {
}
} catch (err) {
console.error('[Store] WebSocket connection failed:', err)
if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err)
// Don't mark as disconnected immediately - let reconnection logic handle it
// The WebSocket client will retry automatically
isReconnecting.value = true
@@ -215,13 +211,13 @@ export const useAppStore = defineStore('app', () => {
await initializeData()
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket reconnection failed, will retry:', err)
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
isReconnecting.value = true
})
return true
} catch (err) {
console.error('[Store] Session check failed:', err)
if (import.meta.env.DEV) console.error('[Store] Session check failed:', err)
localStorage.removeItem('neode-auth')
isAuthenticated.value = false
sessionValidated = false
+23 -2
View File
@@ -5,11 +5,25 @@ import { ref } from 'vue'
* Verified by checking response headers from each app container.
* These always open in a new tab. Other apps load in the iframe overlay.
*/
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
* Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked).
*/
const IFRAME_BLOCKED_HOSTS: string[] = []
/** External sites proxied through nginx to strip X-Frame-Options for iframe embedding */
const EXTERNAL_PROXY: Record<string, string> = {
'botfights.net': '/ext/botfights/',
'484.kitchen': '/ext/484-kitchen/',
'present.l484.com': '/ext/arch-presentation/',
}
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
// External sites — third-party cookie/iframe restrictions
if (u.hostname.includes('indeehub')) return true
// External sites that block iframes
if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
return true
}
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
if (
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
@@ -67,6 +81,13 @@ function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// External sites proxied through nginx to strip X-Frame-Options
const extProxy = EXTERNAL_PROXY[u.hostname]
if (extProxy) {
return `${origin}${extProxy}`
}
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
+10
View File
@@ -95,6 +95,14 @@ export const useCloudStore = defineStore('cloud', () => {
return fileBrowserClient.downloadUrl(path)
}
async function fetchBlobUrl(path: string): Promise<string> {
return fileBrowserClient.fetchBlobUrl(path)
}
async function downloadFile(path: string): Promise<void> {
return fileBrowserClient.downloadFile(path)
}
function reset(): void {
currentPath.value = '/'
items.value = []
@@ -116,6 +124,8 @@ export const useCloudStore = defineStore('cloud', () => {
uploadFile,
deleteItem,
downloadUrl,
fetchBlobUrl,
downloadFile,
reset,
}
})
+2 -2
View File
@@ -165,7 +165,7 @@ export const useContainerStore = defineStore('container', () => {
containers.value = await containerClient.listContainers()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch containers'
console.error('Failed to fetch containers:', e)
if (import.meta.env.DEV) console.error('Failed to fetch containers:', e)
} finally {
loading.value = false
}
@@ -175,7 +175,7 @@ export const useContainerStore = defineStore('container', () => {
try {
healthStatus.value = await containerClient.getHealthStatus()
} catch (e) {
console.error('Failed to fetch health status:', e)
if (import.meta.env.DEV) console.error('Failed to fetch health status:', e)
}
}