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:
co-authored by
Claude Opus 4.6
parent
fd2a837bea
commit
f07ce10b1a
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { containerClient } from '../container-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('containerClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('installApp calls container-install with manifest path', async () => {
|
||||
mockedRpc.call.mockResolvedValue('container-abc123')
|
||||
|
||||
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
|
||||
})
|
||||
expect(result).toBe('container-abc123')
|
||||
})
|
||||
|
||||
it('startContainer calls container-start with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-start',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
})
|
||||
|
||||
it('stopContainer calls container-stop with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopContainer('lnd')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-stop',
|
||||
params: { app_id: 'lnd' },
|
||||
})
|
||||
})
|
||||
|
||||
it('removeContainer calls container-remove with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.removeContainer('mempool')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-remove',
|
||||
params: { app_id: 'mempool' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status for a container', async () => {
|
||||
const mockStatus = {
|
||||
id: '1',
|
||||
name: 'bitcoin-knots',
|
||||
state: 'running' as const,
|
||||
image: 'bitcoinknots:29',
|
||||
created: '2026-01-01',
|
||||
ports: ['8332'],
|
||||
lan_address: 'http://localhost:8332',
|
||||
}
|
||||
mockedRpc.call.mockResolvedValue(mockStatus)
|
||||
|
||||
const result = await containerClient.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-status',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
expect(result).toEqual(mockStatus)
|
||||
})
|
||||
|
||||
it('getContainerLogs returns log lines with default line count', async () => {
|
||||
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
|
||||
mockedRpc.call.mockResolvedValue(mockLogs)
|
||||
|
||||
const result = await containerClient.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'bitcoin-knots', lines: 100 },
|
||||
})
|
||||
expect(result).toEqual(mockLogs)
|
||||
})
|
||||
|
||||
it('getContainerLogs respects custom line count', async () => {
|
||||
mockedRpc.call.mockResolvedValue([])
|
||||
|
||||
await containerClient.getContainerLogs('lnd', 50)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'lnd', lines: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listContainers returns all containers', async () => {
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
|
||||
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
|
||||
]
|
||||
mockedRpc.call.mockResolvedValue(mockContainers)
|
||||
|
||||
const result = await containerClient.listContainers()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getHealthStatus returns health map', async () => {
|
||||
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
|
||||
mockedRpc.call.mockResolvedValue(mockHealth)
|
||||
|
||||
const result = await containerClient.getHealthStatus()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toEqual(mockHealth)
|
||||
})
|
||||
|
||||
it('startBundledApp sends full app config', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
const app = {
|
||||
id: 'filebrowser',
|
||||
name: 'FileBrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
}
|
||||
|
||||
await containerClient.startBundledApp(app)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-start',
|
||||
params: {
|
||||
app_id: 'filebrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('stopBundledApp calls bundled-app-stop', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopBundledApp('filebrowser')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-stop',
|
||||
params: { app_id: 'filebrowser' },
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates RPC errors from the client', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
|
||||
|
||||
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sanitizePath } from '../filebrowser-client'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
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' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Import after stubs
|
||||
const { fileBrowserClient } = await import('../filebrowser-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('authenticates and stores token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
|
||||
|
||||
// We need a fresh instance to test login — use the exported singleton
|
||||
const result = await fileBrowserClient.login('admin', 'admin')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/app/filebrowser/api/login'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false on failed login', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
const result = await fileBrowserClient.login('admin', 'wrong')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fileBrowserClient.login()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists items in a directory', async () => {
|
||||
// Ensure authenticated first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockItems = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 1,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
|
||||
|
||||
const items = await fileBrowserClient.listDirectory('/')
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.name).toBe('photos')
|
||||
expect(items[1]!.extension).toBe('txt')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
||||
|
||||
await fileBrowserClient.listDirectory('photos')
|
||||
|
||||
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadUrl', () => {
|
||||
it('constructs download URL for file path', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
|
||||
|
||||
expect(url).toContain('/api/raw/photos/sunset.jpg')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('file.txt')
|
||||
|
||||
expect(url).toContain('/api/raw/file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
it('uploads a file to the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
await fileBrowserClient.upload('/documents', file)
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/test.txt')
|
||||
expect(url).toContain('override=true')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(file)
|
||||
})
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
||||
const file = new File(['data'], 'big.bin')
|
||||
|
||||
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a folder at the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.createFolder('/documents', 'photos')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/photos/')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('sends DELETE request for the item', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.deleteItem('/photos/old.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsage', () => {
|
||||
it('returns usage summary for root directory', async () => {
|
||||
// Login first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockData = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 2,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage.totalSize).toBe(2000)
|
||||
expect(usage.folderCount).toBe(1)
|
||||
expect(usage.fileCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('identifies text file extensions', () => {
|
||||
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for binary files', () => {
|
||||
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('sends PATCH request with new destination', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
|
||||
})
|
||||
|
||||
it('throws on rename failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
||||
|
||||
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizePath', () => {
|
||||
it('returns / for empty path', () => {
|
||||
expect(sanitizePath('')).toBe('/')
|
||||
})
|
||||
|
||||
it('preserves simple paths', () => {
|
||||
expect(sanitizePath('/photos')).toBe('/photos')
|
||||
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
|
||||
})
|
||||
|
||||
it('adds leading slash', () => {
|
||||
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves . segments', () => {
|
||||
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves .. segments', () => {
|
||||
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
it('prevents traversal past root', () => {
|
||||
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
|
||||
expect(sanitizePath('/../../..')).toBe('/')
|
||||
})
|
||||
|
||||
it('handles multiple consecutive .. at root', () => {
|
||||
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
|
||||
})
|
||||
|
||||
it('handles mixed . and .. segments', () => {
|
||||
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
|
||||
})
|
||||
|
||||
it('removes trailing slashes in segments', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Import after stubbing fetch
|
||||
const { rpcClient } = await import('../rpc-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve(body),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status, statusText),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('marketplaceDiscover', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns apps array and relay_count on success', async () => {
|
||||
const payload = {
|
||||
apps: [
|
||||
{
|
||||
manifest: {
|
||||
app_id: 'bitcoin',
|
||||
name: 'Bitcoin Core',
|
||||
version: '27.0',
|
||||
description: { short: 'Full node', long: 'Bitcoin Core full node' },
|
||||
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
|
||||
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
|
||||
category: 'bitcoin',
|
||||
icon_url: '/icons/bitcoin.png',
|
||||
repo_url: 'https://github.com/bitcoin/bitcoin',
|
||||
license: 'MIT',
|
||||
},
|
||||
trust_score: 95,
|
||||
trust_tier: 'verified',
|
||||
relay_count: 8,
|
||||
first_seen: '2025-01-15T00:00:00Z',
|
||||
nostr_pubkey: 'npub1abc',
|
||||
},
|
||||
],
|
||||
relay_count: 12,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toHaveLength(1)
|
||||
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
|
||||
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
|
||||
expect(result.apps[0]!.trust_score).toBe(95)
|
||||
expect(result.relay_count).toBe(12)
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('marketplace.discover')
|
||||
expect(body.params).toEqual({})
|
||||
})
|
||||
|
||||
it('handles empty results', async () => {
|
||||
const payload = {
|
||||
apps: [],
|
||||
relay_count: 0,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toEqual([])
|
||||
expect(result.apps).toHaveLength(0)
|
||||
expect(result.relay_count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns expected fields', async () => {
|
||||
const payload = {
|
||||
used_bytes: 500_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 500_000_000_000,
|
||||
used_percent: 50,
|
||||
level: 'ok' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.used_bytes).toBe(500_000_000_000)
|
||||
expect(result.total_bytes).toBe(1_000_000_000_000)
|
||||
expect(result.free_bytes).toBe(500_000_000_000)
|
||||
expect(result.used_percent).toBe(50)
|
||||
expect(result.level).toBe('ok')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-status')
|
||||
})
|
||||
|
||||
it('level is warning when percent >= 85', async () => {
|
||||
const payload = {
|
||||
used_bytes: 850_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 150_000_000_000,
|
||||
used_percent: 85,
|
||||
level: 'warning' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('warning')
|
||||
expect(result.used_percent).toBe(85)
|
||||
})
|
||||
|
||||
it('level is critical when percent >= 90', async () => {
|
||||
const payload = {
|
||||
used_bytes: 950_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 50_000_000_000,
|
||||
used_percent: 95,
|
||||
level: 'critical' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('critical')
|
||||
expect(result.used_percent).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskCleanup', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns freed_bytes and actions array', async () => {
|
||||
const payload = {
|
||||
freed_bytes: 2_000_000_000,
|
||||
freed_human: '2 GB',
|
||||
actions: ['Removed 5 dangling images', 'Cleared build cache'],
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskCleanup()
|
||||
|
||||
expect(result.freed_bytes).toBe(2_000_000_000)
|
||||
expect(result.freed_human).toBe('2 GB')
|
||||
expect(result.actions).toHaveLength(2)
|
||||
expect(result.actions[0]).toBe('Removed 5 dangling images')
|
||||
expect(result.actions[1]).toBe('Cleared build cache')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-cleanup')
|
||||
})
|
||||
|
||||
it('uses 60s timeout', async () => {
|
||||
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
mockFetch.mockRejectedValue(abortError)
|
||||
|
||||
const promise = rpcClient.diskCleanup()
|
||||
|
||||
// The call should eventually reject with timeout after retries
|
||||
await expect(promise).rejects.toThrow('Request timeout')
|
||||
|
||||
// Verify all 3 attempts used the signal (timeout is set via AbortController)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
expect(call[1].signal).toBeInstanceOf(AbortSignal)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,26 @@ interface FileBrowserListResponse {
|
||||
sorting: { by: string; asc: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
|
||||
* Always returns a path starting with `/` and never containing `..`.
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
const resolved: string[] = []
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '..') {
|
||||
resolved.pop() // go up one level, but never past root
|
||||
} else {
|
||||
resolved.push(seg)
|
||||
}
|
||||
}
|
||||
|
||||
return '/' + resolved.join('/')
|
||||
}
|
||||
|
||||
class FileBrowserClient {
|
||||
private token: string | null = null
|
||||
private baseUrl: string
|
||||
@@ -38,6 +58,8 @@ class FileBrowserClient {
|
||||
const text = await res.text()
|
||||
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
||||
this.token = text.replace(/^"|"$/g, '')
|
||||
// Store token as cookie for img/video/audio src requests (avoids token in URL)
|
||||
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -51,7 +73,7 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -63,14 +85,47 @@ class FileBrowserClient {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
|
||||
* Returns a plain URL (no token in query string).
|
||||
*/
|
||||
downloadUrl(path: string): string {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
// Token is passed as query param for direct downloads (img src, audio src, etc.)
|
||||
return `${this.baseUrl}/api/raw${safePath}?auth=${this.token}`
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a file as a blob URL using header-based auth (no token in URL).
|
||||
* Use this for img/video/audio src attributes and download links.
|
||||
*/
|
||||
async fetchBlobUrl(path: string): Promise<string> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download using header-based auth (no token in URL).
|
||||
*/
|
||||
async downloadFile(path: string): Promise<void> {
|
||||
const blobUrl = await this.fetchBlobUrl(path)
|
||||
const filename = path.split('/').pop() || 'download'
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
const safePath = dirPath.endsWith('/') ? dirPath : `${dirPath}/`
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
const res = await fetch(
|
||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||
@@ -87,8 +142,10 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||
const safePath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${name}/`, {
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -96,7 +153,7 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers(),
|
||||
@@ -142,7 +199,7 @@ class FileBrowserClient {
|
||||
if (!this.isTextFile(path)) {
|
||||
throw new Error(`Cannot read binary file: ${path}`)
|
||||
}
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -156,15 +213,16 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async rename(oldPath: string, newName: string): Promise<void> {
|
||||
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...this.headers(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ destination: `${dir}${newName}` }),
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
||||
}
|
||||
|
||||
@@ -211,6 +211,62 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async resolveDid(did?: string): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.resolve-did',
|
||||
params: did ? { did } : {},
|
||||
})
|
||||
}
|
||||
|
||||
async createPresentation(params: {
|
||||
holderId: string
|
||||
credentialIds: string[]
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.create-presentation',
|
||||
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
|
||||
})
|
||||
}
|
||||
|
||||
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
|
||||
valid: boolean
|
||||
holder_valid: boolean
|
||||
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'identity.verify-presentation',
|
||||
params: { presentation },
|
||||
})
|
||||
}
|
||||
|
||||
async createPsbt(params: {
|
||||
outputs: Array<{ address: string; amount_sats: number }>
|
||||
feeRateSatPerVbyte?: number
|
||||
}): Promise<{
|
||||
psbt_base64: string
|
||||
change_output_index: number
|
||||
total_amount_sats: number
|
||||
fee_rate_sat_per_vbyte: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.create-psbt',
|
||||
params: {
|
||||
outputs: params.outputs,
|
||||
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async finalizePsbt(signedPsbtBase64: string): Promise<{
|
||||
raw_final_tx: string
|
||||
broadcast: boolean
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.finalize-psbt',
|
||||
params: { signed_psbt_base64: signedPsbtBase64 },
|
||||
})
|
||||
}
|
||||
|
||||
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-publish',
|
||||
@@ -325,6 +381,21 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async detectUsbDevices(): Promise<{
|
||||
devices: Array<{
|
||||
type: string
|
||||
vendor_id: string
|
||||
product_id: string
|
||||
manufacturer: string
|
||||
product: string
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.detect-usb-devices',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restartServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.restart',
|
||||
@@ -381,6 +452,226 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Federation
|
||||
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationJoin(code: string): Promise<{
|
||||
joined: boolean
|
||||
node: { did: string; onion: string; pubkey: string; trust_level: string }
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.join',
|
||||
params: { code },
|
||||
})
|
||||
}
|
||||
|
||||
async federationListNodes(): Promise<{
|
||||
nodes: Array<{
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
trust_level: string
|
||||
added_at: string
|
||||
name?: string
|
||||
last_seen?: string
|
||||
last_state?: {
|
||||
timestamp: string
|
||||
apps: Array<{ id: string; status: string; version?: string }>
|
||||
cpu_usage_percent?: number
|
||||
mem_used_bytes?: number
|
||||
mem_total_bytes?: number
|
||||
disk_used_bytes?: number
|
||||
disk_total_bytes?: number
|
||||
uptime_secs?: number
|
||||
tor_active?: boolean
|
||||
}
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.list-nodes',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
|
||||
return this.call({
|
||||
method: 'federation.remove-node',
|
||||
params: { did },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSyncState(): Promise<{
|
||||
synced: number
|
||||
failed: number
|
||||
results: Array<{ did: string; status: string; apps?: number; error?: string }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.sync-state',
|
||||
params: {},
|
||||
timeout: 120000,
|
||||
})
|
||||
}
|
||||
|
||||
async federationDeployApp(params: {
|
||||
did: string
|
||||
appId: string
|
||||
version?: string
|
||||
marketplaceUrl?: string
|
||||
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.deploy-app',
|
||||
params: {
|
||||
did: params.did,
|
||||
app_id: params.appId,
|
||||
version: params.version ?? 'latest',
|
||||
marketplace_url: params.marketplaceUrl ?? '',
|
||||
},
|
||||
timeout: 180000,
|
||||
})
|
||||
}
|
||||
|
||||
// VPN
|
||||
async vpnStatus(): Promise<{
|
||||
connected: boolean
|
||||
provider?: string
|
||||
interface?: string
|
||||
ip_address?: string
|
||||
hostname?: string
|
||||
peers_connected: number
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
configured: boolean
|
||||
configured_provider: string
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'vpn.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async vpnConfigure(params: {
|
||||
provider: 'tailscale' | 'wireguard'
|
||||
auth_key?: string
|
||||
address?: string
|
||||
dns?: string
|
||||
peer?: {
|
||||
public_key: string
|
||||
endpoint: string
|
||||
allowed_ips?: string
|
||||
persistent_keepalive?: number
|
||||
}
|
||||
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
|
||||
return this.call({
|
||||
method: 'vpn.configure',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
|
||||
return this.call({
|
||||
method: 'vpn.disconnect',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Marketplace
|
||||
async marketplaceDiscover(): Promise<{
|
||||
apps: Array<{
|
||||
manifest: {
|
||||
app_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: { short: string; long: string } | string
|
||||
author: { name: string; did: string; nostr_pubkey: string }
|
||||
container: { image: string; ports: Array<{ container: number; host: number }>; }
|
||||
category: string
|
||||
icon_url: string
|
||||
repo_url: string
|
||||
license: string
|
||||
}
|
||||
trust_score: number
|
||||
trust_tier: string
|
||||
relay_count: number
|
||||
first_seen: string
|
||||
nostr_pubkey: string
|
||||
}>
|
||||
relay_count: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'marketplace.discover',
|
||||
params: {},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
// DNS
|
||||
async dnsStatus(): Promise<{
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
resolv_conf_servers: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.dns-status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async configureDns(params: {
|
||||
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
|
||||
servers?: string[]
|
||||
}): Promise<{
|
||||
ok: boolean
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.configure-dns',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// Disk management
|
||||
async diskStatus(): Promise<{
|
||||
used_bytes: number
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
used_percent: number
|
||||
level: 'ok' | 'warning' | 'critical'
|
||||
}> {
|
||||
return this.call({ method: 'system.disk-status' })
|
||||
}
|
||||
|
||||
async diskCleanup(): Promise<{
|
||||
freed_bytes: number
|
||||
freed_human: string
|
||||
actions: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.disk-cleanup',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import type { Update, PatchOperation } from '../types/api'
|
||||
import { applyPatch, type Operation } from 'fast-json-patch'
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
|
||||
|
||||
type WebSocketCallback = (update: Update) => void
|
||||
type ConnectionStateCallback = (connected: boolean) => void
|
||||
type ConnectionStateCallback = (state: ConnectionState) => void
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
@@ -13,14 +15,18 @@ export class WebSocketClient {
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private maxReconnectDelay = 30000
|
||||
private shouldReconnect = true
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private visibilityChangeHandler: (() => void) | null = null
|
||||
private onlineHandler: (() => void) | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private lastMessageTime: number = Date.now()
|
||||
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
||||
private pingInterval = 30000 // Send ping every 30 seconds
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
|
||||
constructor(url: string = '/ws/db') {
|
||||
this.url = url
|
||||
@@ -33,13 +39,13 @@ export class WebSocketClient {
|
||||
// Handle page visibility changes (tab switching, browser minimizing)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.log('[WebSocket] Page became visible, checking connection...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
||||
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -50,11 +56,11 @@ export class WebSocketClient {
|
||||
this.onlineHandler = () => {
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (!this.shouldReconnect) return
|
||||
console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,14 +71,14 @@ export class WebSocketClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already connected, resolve immediately
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Already connected, skipping')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// If connecting, wait for it
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.log('[WebSocket] Already connecting, waiting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
@@ -107,7 +113,7 @@ export class WebSocketClient {
|
||||
|
||||
// If we have an active WebSocket, don't create a new one
|
||||
if (this.ws) {
|
||||
console.log('[WebSocket] Connection exists, reusing it')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
@@ -124,14 +130,15 @@ export class WebSocketClient {
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}${this.url}`
|
||||
|
||||
console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
|
||||
this.setConnectionState('connecting')
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
// Timeout handler in case connection hangs
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.warn('WebSocket connection timeout, retrying...')
|
||||
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
|
||||
this.ws.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
@@ -141,15 +148,15 @@ export class WebSocketClient {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.reconnectAttempts = 0
|
||||
this.lastMessageTime = Date.now()
|
||||
console.log('[WebSocket] Connected successfully')
|
||||
this.notifyConnectionState(true)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
|
||||
this.setConnectionState('connected')
|
||||
this.startHeartbeat()
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
console.error('[WebSocket] Connection error:', error)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
|
||||
// Don't reject immediately - let onclose handle reconnection
|
||||
// This prevents errors from blocking reconnection
|
||||
}
|
||||
@@ -160,24 +167,24 @@ export class WebSocketClient {
|
||||
const update: Update = JSON.parse(event.data)
|
||||
this.callbacks.forEach((callback) => callback(update))
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.stopHeartbeat()
|
||||
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
|
||||
// Notify connection state changed
|
||||
this.notifyConnectionState(false)
|
||||
this.setConnectionState('disconnected')
|
||||
|
||||
// Clear the WebSocket reference
|
||||
this.ws = null
|
||||
|
||||
// Don't reconnect if we explicitly disconnected
|
||||
if (!this.shouldReconnect) {
|
||||
console.log('[WebSocket] Reconnection disabled')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,11 +197,11 @@ export class WebSocketClient {
|
||||
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
||||
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
||||
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
|
||||
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
@@ -213,9 +220,9 @@ export class WebSocketClient {
|
||||
this.reconnectAttempts++
|
||||
}
|
||||
|
||||
console.log('[WebSocket] Attempting reconnection...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
|
||||
this.connect().catch((err) => {
|
||||
console.error('[WebSocket] Reconnection failed:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
|
||||
// onclose will be called again and will retry
|
||||
})
|
||||
}
|
||||
@@ -230,7 +237,7 @@ export class WebSocketClient {
|
||||
}, delay)
|
||||
}
|
||||
} else {
|
||||
console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
this.shouldReconnect = false
|
||||
}
|
||||
}
|
||||
@@ -244,6 +251,10 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||
this.connectionStateCallbacks.add(callback)
|
||||
return () => {
|
||||
@@ -251,26 +262,38 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyConnectionState(connected: boolean): void {
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(connected))
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
this._state = state
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
|
||||
// Send ping messages every 30s
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} catch {
|
||||
// Send failed, connection likely broken
|
||||
}
|
||||
}
|
||||
}, this.pingInterval)
|
||||
|
||||
// Check connection health every 10s
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[WebSocket] Heartbeat detected closed connection')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we've received a message recently
|
||||
|
||||
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
||||
|
||||
// If no message for more than 5 minutes, assume connection is stale
|
||||
if (timeSinceLastMessage > 300000) {
|
||||
console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
this.ws.close()
|
||||
return
|
||||
}
|
||||
@@ -282,11 +305,16 @@ export class WebSocketClient {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.reconnectAttempts = 0
|
||||
this.setConnectionState('disconnecting')
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Clear reconnect timer
|
||||
@@ -345,7 +373,7 @@ function getWebSocketClient(): WebSocketClient {
|
||||
if (existing && existing instanceof WebSocketClient) {
|
||||
// Check if the WebSocket is still valid
|
||||
if (existing.isConnected()) {
|
||||
console.log('[WebSocket] Using existing connected client from HMR')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
|
||||
wsClientInstance = existing
|
||||
return existing
|
||||
}
|
||||
@@ -374,7 +402,7 @@ export const wsClient: WebSocketClient = (() => {
|
||||
_wsClient = getWebSocketClient()
|
||||
return _wsClient
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Error initializing client:', error)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
|
||||
// Fallback to new instance
|
||||
_wsClient = new WebSocketClient()
|
||||
return _wsClient
|
||||
@@ -385,7 +413,7 @@ export const wsClient: WebSocketClient = (() => {
|
||||
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
// Validate patch is an array before applying
|
||||
if (!Array.isArray(patch) || patch.length === 0) {
|
||||
console.warn('Invalid or empty patch received, returning original data')
|
||||
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -393,7 +421,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
const result = applyPatch(data, patch as Operation[], false, false)
|
||||
return result.newDocument as T
|
||||
} catch (error) {
|
||||
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
return data // Return original data on error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="monitoring-chart"
|
||||
:width="width"
|
||||
:height="height"
|
||||
></canvas>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string
|
||||
data: number[]
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
datasets: ChartDataset[]
|
||||
labels?: string[]
|
||||
width?: number
|
||||
height?: number
|
||||
yMax?: number
|
||||
yLabel?: string
|
||||
showGrid?: boolean
|
||||
}>(),
|
||||
{
|
||||
width: 400,
|
||||
height: 180,
|
||||
showGrid: true,
|
||||
},
|
||||
)
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = props.width * dpr
|
||||
canvas.height = props.height * dpr
|
||||
canvas.style.width = `${props.width}px`
|
||||
canvas.style.height = `${props.height}px`
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const w = props.width
|
||||
const h = props.height
|
||||
const pad = { top: 10, right: 12, bottom: 24, left: 44 }
|
||||
const plotW = w - pad.left - pad.right
|
||||
const plotH = h - pad.top - pad.bottom
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (!props.datasets.length || !props.datasets[0]?.data.length) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)'
|
||||
ctx.font = '12px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No data yet', w / 2, h / 2)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute y range
|
||||
let yMax = props.yMax ?? 0
|
||||
if (!yMax) {
|
||||
for (const ds of props.datasets) {
|
||||
for (const v of ds.data) {
|
||||
if (v > yMax) yMax = v
|
||||
}
|
||||
}
|
||||
yMax = yMax * 1.1 || 1
|
||||
}
|
||||
|
||||
const maxPoints = Math.max(...props.datasets.map((d) => d.data.length))
|
||||
|
||||
// Grid lines
|
||||
if (props.showGrid) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
const gridCount = 4
|
||||
for (let i = 0; i <= gridCount; i++) {
|
||||
const y = pad.top + (plotH / gridCount) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(pad.left + plotW, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'right'
|
||||
for (let i = 0; i <= gridCount; i++) {
|
||||
const y = pad.top + (plotH / gridCount) * i
|
||||
const val = yMax - (yMax / gridCount) * i
|
||||
ctx.fillText(formatValue(val), pad.left - 6, y + 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw each dataset
|
||||
for (const ds of props.datasets) {
|
||||
if (!ds.data.length) continue
|
||||
|
||||
ctx.strokeStyle = ds.color
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < ds.data.length; i++) {
|
||||
const x = pad.left + (i / Math.max(maxPoints - 1, 1)) * plotW
|
||||
const y = pad.top + plotH - (ds.data[i]! / yMax) * plotH
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Area fill
|
||||
ctx.globalAlpha = 0.08
|
||||
ctx.fillStyle = ds.color
|
||||
ctx.lineTo(pad.left + ((ds.data.length - 1) / Math.max(maxPoints - 1, 1)) * plotW, pad.top + plotH)
|
||||
ctx.lineTo(pad.left, pad.top + plotH)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
|
||||
// X-axis labels (first, middle, last)
|
||||
if (props.labels && props.labels.length > 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
const indices = [0, Math.floor(props.labels.length / 2), props.labels.length - 1]
|
||||
for (const idx of indices) {
|
||||
if (idx >= 0 && idx < props.labels.length) {
|
||||
const x = pad.left + (idx / Math.max(props.labels.length - 1, 1)) * plotW
|
||||
ctx.fillText(props.labels[idx]!, pad.left + plotW + pad.right > w ? x : x, h - 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(val: number): string {
|
||||
if (val >= 1_000_000_000) return `${(val / 1_000_000_000).toFixed(1)}G`
|
||||
if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`
|
||||
if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`
|
||||
return val.toFixed(val < 10 ? 1 : 0)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.datasets, props.labels, props.width, props.height],
|
||||
() => draw(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
draw()
|
||||
window.addEventListener('resize', draw)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', draw)
|
||||
})
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@ onMounted(() => {
|
||||
// Don't show if already dismissed this session or if already installed
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === '1') return
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return
|
||||
if ((window.navigator as any).standalone) return
|
||||
if ((window.navigator as Navigator & { standalone?: boolean }).standalone) return
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault()
|
||||
@@ -55,11 +55,11 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler)
|
||||
;(window as any).__beforeinstallpromptHandler = handler
|
||||
;(window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler = handler
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeinstallprompt', (window as any).__beforeinstallpromptHandler)
|
||||
window.removeEventListener('beforeinstallprompt', (window as Window & { __beforeinstallpromptHandler?: EventListener }).__beforeinstallpromptHandler as EventListener)
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
|
||||
@@ -205,7 +205,7 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
if ((welcome || logo) && videoElement.value) {
|
||||
if (videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed:', err)
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed:', err)
|
||||
})
|
||||
}
|
||||
// Add pause prevention handler once, remove when no longer needed
|
||||
@@ -228,7 +228,7 @@ watch(showWelcome, (isShowing) => {
|
||||
if (isShowing && videoElement.value) {
|
||||
// Start video immediately when welcome appears
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on welcome:', err)
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -414,7 +414,7 @@ function startAlienIntro() {
|
||||
playWelcomeNoderunnerSpeech()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on welcome:', err)
|
||||
if (import.meta.env.DEV) console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
backgroundOpacity.value = 0.3
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import LineChart from '../LineChart.vue'
|
||||
|
||||
// Mock canvas context
|
||||
const mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
createLinearGradient: vi.fn().mockReturnValue({
|
||||
addColorStop: vi.fn(),
|
||||
}),
|
||||
canvas: { width: 600, height: 200 },
|
||||
strokeStyle: '',
|
||||
fillStyle: '',
|
||||
lineWidth: 0,
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
globalAlpha: 1,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
|
||||
})
|
||||
|
||||
describe('LineChart', () => {
|
||||
const sampleDatasets = [
|
||||
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
|
||||
]
|
||||
|
||||
it('renders a canvas element', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
expect(wrapper.find('canvas').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts datasets prop', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders with empty datasets', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: [] },
|
||||
})
|
||||
expect(wrapper.find('canvas').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders with multiple datasets', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: [
|
||||
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
|
||||
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts optional height and width props', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: sampleDatasets,
|
||||
height: 300,
|
||||
width: 600,
|
||||
},
|
||||
})
|
||||
const canvas = wrapper.find('canvas')
|
||||
expect(canvas.attributes('width')).toBe('600')
|
||||
expect(canvas.attributes('height')).toBe('300')
|
||||
})
|
||||
|
||||
it('uses default width of 400 and height of 180', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: { datasets: sampleDatasets },
|
||||
})
|
||||
const canvas = wrapper.find('canvas')
|
||||
expect(canvas.attributes('width')).toBe('400')
|
||||
expect(canvas.attributes('height')).toBe('180')
|
||||
})
|
||||
|
||||
it('renders with dataset containing single data point', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts yMax and yLabel props', () => {
|
||||
const wrapper = shallowMount(LineChart, {
|
||||
props: {
|
||||
datasets: sampleDatasets,
|
||||
yMax: 100,
|
||||
yLabel: 'Percent',
|
||||
},
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
|
||||
|
||||
describe('PWAInstallPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionStorage.clear()
|
||||
// Mock matchMedia to return non-standalone
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders without errors', () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show prompt initially', () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('shows prompt after beforeinstallprompt event', async () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Fire the beforeinstallprompt event
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('hides prompt when dismissed', async () => {
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Show prompt
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Click dismiss button
|
||||
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
|
||||
expect(dismissBtn).toBeDefined()
|
||||
await dismissBtn!.trigger('click')
|
||||
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('does not show if already dismissed this session', async () => {
|
||||
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
// Fire beforeinstallprompt — should not show
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
|
||||
it('does not show in standalone mode', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
|
||||
const wrapper = shallowMount(PWAInstallPrompt, {
|
||||
global: { stubs: { Teleport: true, Transition: true } },
|
||||
})
|
||||
|
||||
const event = new Event('beforeinstallprompt')
|
||||
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
|
||||
window.dispatchEvent(event)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Install Archipelago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAudioPlayer } from '../useAudioPlayer'
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
class MockAudio {
|
||||
src = ''
|
||||
currentTime = 0
|
||||
duration = 120
|
||||
paused = true
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// no-op for tests
|
||||
}
|
||||
|
||||
play() {
|
||||
this.paused = false
|
||||
this.emit('play')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true
|
||||
this.emit('pause')
|
||||
}
|
||||
|
||||
private emit(event: string) {
|
||||
const handlers = this.listeners[event] || []
|
||||
handlers.forEach(h => h())
|
||||
}
|
||||
|
||||
// Helper to simulate events in tests
|
||||
simulateEvent(event: string) {
|
||||
this.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
describe('useAudioPlayer', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton state by stopping any active playback
|
||||
const player = useAudioPlayer()
|
||||
player.stop()
|
||||
})
|
||||
|
||||
it('returns all expected properties', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.play).toBeTypeOf('function')
|
||||
expect(player.pause).toBeTypeOf('function')
|
||||
expect(player.seek).toBeTypeOf('function')
|
||||
expect(player.stop).toBeTypeOf('function')
|
||||
expect(player.playing).toBeDefined()
|
||||
expect(player.currentName).toBeDefined()
|
||||
expect(player.currentTime).toBeDefined()
|
||||
expect(player.duration).toBeDefined()
|
||||
expect(player.progress).toBeDefined()
|
||||
expect(player.currentSrc).toBeDefined()
|
||||
expect(player.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts in stopped state', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('play sets playing state and current source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test Track')
|
||||
expect(player.playing.value).toBe(true)
|
||||
expect(player.currentSrc.value).toBe('/audio/test.mp3')
|
||||
expect(player.currentName.value).toBe('Test Track')
|
||||
})
|
||||
|
||||
it('play toggles pause when same source is playing', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(true)
|
||||
// Play same source again — should pause
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('play switches to new source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/first.mp3', 'First')
|
||||
player.play('/audio/second.mp3', 'Second')
|
||||
expect(player.currentSrc.value).toBe('/audio/second.mp3')
|
||||
expect(player.currentName.value).toBe('Second')
|
||||
})
|
||||
|
||||
it('pause pauses playback', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.pause()
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stop resets all state', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.stop()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('progress computes correctly', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.progress.value).toBe(0) // duration is 0
|
||||
|
||||
player.currentTime.value = 30
|
||||
player.duration.value = 120
|
||||
expect(player.progress.value).toBe(25) // 30/120 * 100
|
||||
})
|
||||
|
||||
it('progress is 0 when duration is 0', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.duration.value = 0
|
||||
player.currentTime.value = 10
|
||||
expect(player.progress.value).toBe(0)
|
||||
})
|
||||
|
||||
it('shared state across multiple useAudioPlayer calls', () => {
|
||||
const p1 = useAudioPlayer()
|
||||
const p2 = useAudioPlayer()
|
||||
p1.play('/audio/shared.mp3', 'Shared')
|
||||
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
|
||||
expect(p2.playing.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('returns folder for directories', () => {
|
||||
expect(getFileCategory('', true)).toBe('folder')
|
||||
expect(getFileCategory('jpg', true)).toBe('folder')
|
||||
})
|
||||
|
||||
it('identifies image extensions', () => {
|
||||
expect(getFileCategory('jpg', false)).toBe('image')
|
||||
expect(getFileCategory('jpeg', false)).toBe('image')
|
||||
expect(getFileCategory('png', false)).toBe('image')
|
||||
expect(getFileCategory('gif', false)).toBe('image')
|
||||
expect(getFileCategory('webp', false)).toBe('image')
|
||||
expect(getFileCategory('svg', false)).toBe('image')
|
||||
expect(getFileCategory('bmp', false)).toBe('image')
|
||||
expect(getFileCategory('ico', false)).toBe('image')
|
||||
})
|
||||
|
||||
it('identifies audio extensions', () => {
|
||||
expect(getFileCategory('mp3', false)).toBe('audio')
|
||||
expect(getFileCategory('flac', false)).toBe('audio')
|
||||
expect(getFileCategory('wav', false)).toBe('audio')
|
||||
expect(getFileCategory('ogg', false)).toBe('audio')
|
||||
expect(getFileCategory('aac', false)).toBe('audio')
|
||||
expect(getFileCategory('m4a', false)).toBe('audio')
|
||||
})
|
||||
|
||||
it('identifies video extensions', () => {
|
||||
expect(getFileCategory('mp4', false)).toBe('video')
|
||||
expect(getFileCategory('mkv', false)).toBe('video')
|
||||
expect(getFileCategory('avi', false)).toBe('video')
|
||||
expect(getFileCategory('mov', false)).toBe('video')
|
||||
expect(getFileCategory('webm', false)).toBe('video')
|
||||
})
|
||||
|
||||
it('identifies document extensions', () => {
|
||||
expect(getFileCategory('pdf', false)).toBe('document')
|
||||
expect(getFileCategory('doc', false)).toBe('document')
|
||||
expect(getFileCategory('docx', false)).toBe('document')
|
||||
expect(getFileCategory('txt', false)).toBe('document')
|
||||
expect(getFileCategory('md', false)).toBe('document')
|
||||
})
|
||||
|
||||
it('identifies spreadsheet extensions', () => {
|
||||
expect(getFileCategory('xls', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('csv', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('ods', false)).toBe('spreadsheet')
|
||||
})
|
||||
|
||||
it('identifies archive extensions', () => {
|
||||
expect(getFileCategory('zip', false)).toBe('archive')
|
||||
expect(getFileCategory('tar', false)).toBe('archive')
|
||||
expect(getFileCategory('gz', false)).toBe('archive')
|
||||
expect(getFileCategory('rar', false)).toBe('archive')
|
||||
expect(getFileCategory('7z', false)).toBe('archive')
|
||||
})
|
||||
|
||||
it('returns file for unknown extensions', () => {
|
||||
expect(getFileCategory('xyz', false)).toBe('file')
|
||||
expect(getFileCategory('', false)).toBe('file')
|
||||
expect(getFileCategory('bin', false)).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileType', () => {
|
||||
it('returns correct category and computed values for an image', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
expect(result.isImage.value).toBe(true)
|
||||
expect(result.isAudio.value).toBe(false)
|
||||
expect(result.isVideo.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-blue-400')
|
||||
expect(result.badgeLabel.value).toBe('Image')
|
||||
})
|
||||
|
||||
it('returns correct values for audio', () => {
|
||||
const ext = ref('mp3')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-orange-400')
|
||||
expect(result.badgeLabel.value).toBe('Audio')
|
||||
})
|
||||
|
||||
it('returns correct values for video', () => {
|
||||
const ext = ref('mp4')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('video')
|
||||
expect(result.isVideo.value).toBe(true)
|
||||
expect(result.iconColor.value).toBe('text-purple-400')
|
||||
})
|
||||
|
||||
it('returns folder when isDir is true', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(true)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('folder')
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-amber-400')
|
||||
expect(result.badgeLabel.value).toBe('Folder')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
|
||||
ext.value = 'mp3'
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('provides icon paths for each category', () => {
|
||||
const ext = ref('pdf')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.iconPaths.value).toBeDefined()
|
||||
expect(result.iconPaths.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('provides badge class for each category', () => {
|
||||
const ext = ref('zip')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.badgeClass.value).toContain('bg-yellow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB')
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1.0 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(formatSize(1099511627776)).toBe('1.0 TB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns "Just now" for very recent dates', () => {
|
||||
const now = new Date().toISOString()
|
||||
expect(formatDate(now)).toBe('Just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for recent dates', () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
expect(formatDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for dates within 24h', () => {
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(threeHoursAgo)).toBe('3h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for dates within a week', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(twoDaysAgo)).toBe('2d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older dates', () => {
|
||||
const oldDate = new Date('2025-01-15').toISOString()
|
||||
const result = formatDate(oldDate)
|
||||
// Should be a locale date string, not a relative time
|
||||
expect(result).toMatch(/\d/)
|
||||
expect(result).not.toContain('ago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useMarketplaceApp } from '../useMarketplaceApp'
|
||||
|
||||
describe('useMarketplaceApp', () => {
|
||||
beforeEach(() => {
|
||||
const { clearCurrentApp } = useMarketplaceApp()
|
||||
clearCurrentApp()
|
||||
})
|
||||
|
||||
it('getCurrentApp returns null initially', () => {
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('setCurrentApp stores a full app', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
version: '25.0',
|
||||
icon: '/icons/btc.png',
|
||||
category: 'Finance',
|
||||
description: 'Bitcoin node',
|
||||
author: 'Satoshi',
|
||||
source: 'github',
|
||||
manifestUrl: 'https://example.com/manifest',
|
||||
url: 'https://example.com',
|
||||
repoUrl: 'https://github.com/bitcoin/bitcoin',
|
||||
s9pkUrl: '',
|
||||
dockerImage: 'bitcoin:25.0',
|
||||
})
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('bitcoin')
|
||||
expect(app!.title).toBe('Bitcoin Core')
|
||||
expect(app!.version).toBe('25.0')
|
||||
})
|
||||
|
||||
it('setCurrentApp with partial app fills defaults', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'lnd' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('lnd')
|
||||
expect(app!.title).toBe('')
|
||||
expect(app!.version).toBe('')
|
||||
expect(app!.icon).toBe('')
|
||||
expect(app!.dockerImage).toBe('')
|
||||
})
|
||||
|
||||
it('manifestUrl falls back to s9pkUrl then url', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
|
||||
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
|
||||
})
|
||||
|
||||
it('url falls back to s9pkUrl then manifestUrl', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.url).toBe('https://manifest.example.com')
|
||||
})
|
||||
|
||||
it('clearCurrentApp sets app to null', () => {
|
||||
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'bitcoin' })
|
||||
expect(getCurrentApp()).not.toBeNull()
|
||||
clearCurrentApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('shared state across multiple useMarketplaceApp calls', () => {
|
||||
const instance1 = useMarketplaceApp()
|
||||
const instance2 = useMarketplaceApp()
|
||||
|
||||
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
|
||||
const app = instance2.getCurrentApp()
|
||||
expect(app!.id).toBe('mempool')
|
||||
expect(app!.title).toBe('Mempool')
|
||||
})
|
||||
|
||||
it('handles description as object', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
description: { short: 'Short desc', long: 'Long description' },
|
||||
})
|
||||
const app = getCurrentApp()
|
||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useMessageToast } from '../useMessageToast'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useMessageToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
// Reset shared singleton state
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const toast = useMessageToast()
|
||||
expect(toast.receivedMessages.value).toEqual([])
|
||||
expect(toast.lastMessageCount.value).toBe(0)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('loadReceivedMessages fetches and stores messages', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
|
||||
],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.receivedMessages.value.length).toBe(1)
|
||||
expect(toast.lastMessageCount.value).toBe(1)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show toast on initial load', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
})
|
||||
|
||||
it('shows toast when new messages arrive after initial load', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// New message arrives
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('Second')
|
||||
})
|
||||
|
||||
it('shows count for multiple new messages', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// Multiple new messages
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
|
||||
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('2 new messages')
|
||||
})
|
||||
|
||||
it('unreadCount reflects difference', async () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 1
|
||||
expect(toast.unreadCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('unreadCount is never negative', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 5
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('markAsRead syncs lastMessageCount', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.markAsRead()
|
||||
expect(toast.lastMessageCount.value).toBe(2)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith({ path: '/dashboard/web5', query: { tab: 'messages' } })
|
||||
})
|
||||
|
||||
it('stops polling on 401 error', async () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
toast.startPolling()
|
||||
|
||||
// Wait for initial load triggered by startPolling
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Polling should have stopped, so advancing time should NOT call again
|
||||
vi.clearAllMocks()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('startPolling does not create duplicate timers', () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
// Should only have one timer — verify by stopping and checking no more calls
|
||||
toast.stopPolling()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
describe('useToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Get a fresh toast instance and clear any leftover state
|
||||
const { toasts, dismiss } = useToast()
|
||||
// Dismiss all existing toasts
|
||||
for (const t of [...toasts.value]) {
|
||||
dismiss(t.id)
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates a success toast', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Operation complete')
|
||||
|
||||
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Operation complete')
|
||||
expect(toast.variant).toBe('success')
|
||||
expect(toast.dismissing).toBe(false)
|
||||
})
|
||||
|
||||
it('creates an error toast', () => {
|
||||
const { error, toasts } = useToast()
|
||||
|
||||
error('Something went wrong')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Something went wrong')
|
||||
expect(toast.variant).toBe('error')
|
||||
})
|
||||
|
||||
it('creates an info toast', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('FYI: Node syncing')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('FYI: Node syncing')
|
||||
expect(toast.variant).toBe('info')
|
||||
})
|
||||
|
||||
it('auto-dismisses toast after duration', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Will auto-dismiss')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
const toastId = toast.id
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
|
||||
|
||||
// After 3000ms, the toast should start dismissing
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
const dismissingToast = toasts.value.find((t) => t.id === toastId)
|
||||
if (dismissingToast) {
|
||||
expect(dismissingToast.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After another 300ms, the toast should be fully removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss marks toast as dismissing then removes it', () => {
|
||||
const { info, toasts, dismiss } = useToast()
|
||||
|
||||
info('Dismissable')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
|
||||
dismiss(toast.id)
|
||||
|
||||
// Should be marked as dismissing
|
||||
const found = toasts.value.find((t) => t.id === toast.id)
|
||||
if (found) {
|
||||
expect(found.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After 300ms animation delay, should be removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss is a no-op for nonexistent toast ID', () => {
|
||||
const { dismiss, toasts } = useToast()
|
||||
const countBefore = toasts.value.length
|
||||
|
||||
dismiss(999999)
|
||||
|
||||
expect(toasts.value.length).toBe(countBefore)
|
||||
})
|
||||
|
||||
it('each toast gets a unique ID', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('First')
|
||||
info('Second')
|
||||
info('Third')
|
||||
|
||||
const ids = toasts.value.slice(-3).map((t) => t.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(3)
|
||||
})
|
||||
|
||||
it('caps visible toasts at 5', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
info(`Toast ${i}`)
|
||||
}
|
||||
|
||||
expect(toasts.value.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('toasts ref is readonly', () => {
|
||||
const { toasts } = useToast()
|
||||
// The readonly wrapper prevents direct mutation
|
||||
expect(typeof toasts.value).toBe('object')
|
||||
})
|
||||
})
|
||||
@@ -93,7 +93,7 @@ export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(1, t)
|
||||
introGain.gain.setValueAtTime(introGain.gain.value, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
@@ -104,6 +104,23 @@ export function stopSynthwave() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
|
||||
export function stopAllAudio() {
|
||||
// Stop synthwave loop
|
||||
if (introAudio) {
|
||||
introAudio.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}
|
||||
// Stop intro typing
|
||||
stopIntroTyping()
|
||||
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {})
|
||||
audioContext = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pop sound - plays when intro initiator (tap to start) is pressed */
|
||||
export function playPop() {
|
||||
const audio = new Audio('/assets/audio/pop.mp3')
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface MarketplaceAppInfo {
|
||||
repoUrl: string
|
||||
s9pkUrl: string
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
@@ -36,6 +38,7 @@ export function useMarketplaceApp() {
|
||||
repoUrl: app.repoUrl ?? '',
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useMessageToast() {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
console.error('Failed to load messages:', e)
|
||||
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
"modeEasyDesc": "Goal-based interface. Choose what you want to do, and the system handles the rest.",
|
||||
"modePro": "Pro",
|
||||
"modeProDesc": "Full control over all services. Configure everything manually with all technical details.",
|
||||
"modeChat": "Chat",
|
||||
"modeChat": "AIUI",
|
||||
"modeChatDesc": "Conversational AI interface. Manage your node through natural language. Coming soon."
|
||||
},
|
||||
"marketplace": {
|
||||
@@ -353,8 +353,8 @@
|
||||
"closeAssistant": "Close AI Assistant",
|
||||
"loadingAssistant": "Loading AI assistant...",
|
||||
"aiAssistant": "AI Assistant",
|
||||
"notConfigured": "AI Assistant is not yet configured on this node.",
|
||||
"deployCta": "Deploy the AIUI app from the App Store to enable this feature."
|
||||
"notConfigured": "AI Assistant needs to be enabled before use.",
|
||||
"deployCta": "Go to Settings to configure your AI provider API key, then return here to start chatting."
|
||||
},
|
||||
"web5": {
|
||||
"title": "Web5",
|
||||
@@ -682,6 +682,13 @@
|
||||
"rollbackSuccess": "Rolled back to previous version. Service will restart.",
|
||||
"rollbackFailed": "Rollback failed."
|
||||
},
|
||||
"kiosk": {
|
||||
"pressEsc": "Press ESC to exit",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"escHint": "Press ESC to exit apps",
|
||||
"navHint": "Use arrow keys to navigate"
|
||||
},
|
||||
"kioskRecovery": {
|
||||
"title": "Archipelago Recovery",
|
||||
"subtitle": "Kiosk failsafe — no authentication required",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// Mock the app store module
|
||||
const mockStore = {
|
||||
isAuthenticated: false,
|
||||
isConnected: false,
|
||||
isReconnecting: false,
|
||||
needsSessionValidation: vi.fn().mockReturnValue(false),
|
||||
checkSession: vi.fn().mockResolvedValue(false),
|
||||
connectWebSocket: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => mockStore,
|
||||
}))
|
||||
|
||||
const Stub = defineComponent({ template: '<div />' })
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Stub,
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{ path: '', component: Stub },
|
||||
{ path: 'login', name: 'login', component: Stub },
|
||||
{ path: 'onboarding/intro', name: 'onboarding-intro', component: Stub },
|
||||
{ path: 'onboarding/options', name: 'onboarding-options', component: Stub },
|
||||
{ path: 'onboarding/path', name: 'onboarding-path', component: Stub },
|
||||
{ path: 'onboarding/did', name: 'onboarding-did', component: Stub },
|
||||
{ path: 'onboarding/identity', name: 'onboarding-identity', component: Stub },
|
||||
{ path: 'onboarding/backup', name: 'onboarding-backup', component: Stub },
|
||||
{ path: 'onboarding/verify', name: 'onboarding-verify', component: Stub },
|
||||
{ path: 'onboarding/done', name: 'onboarding-done', component: Stub },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: Stub,
|
||||
children: [
|
||||
{ path: '', name: 'home', component: Stub },
|
||||
{ path: 'apps', name: 'apps', component: Stub },
|
||||
{ path: 'settings', name: 'settings', component: Stub },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('Onboarding Routing Flow', () => {
|
||||
let router: ReturnType<typeof createTestRouter>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.isConnected = false
|
||||
mockStore.isReconnecting = false
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
router = createTestRouter()
|
||||
|
||||
// Add the same beforeEach guard as the real router
|
||||
router.beforeEach(async (to) => {
|
||||
const isPublic = to.meta.public
|
||||
|
||||
if (isPublic) {
|
||||
if (to.path === '/login' && mockStore.isAuthenticated) {
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
return true
|
||||
}
|
||||
return { name: 'home' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mockStore.needsSessionValidation()) {
|
||||
mockStore.checkSession()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!mockStore.isAuthenticated) {
|
||||
const hasSession = await mockStore.checkSession()
|
||||
if (hasSession) return true
|
||||
return '/login'
|
||||
}
|
||||
|
||||
if (!mockStore.isConnected && !mockStore.isReconnecting) {
|
||||
mockStore.connectWebSocket()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
describe('unauthenticated users', () => {
|
||||
it('redirects to /login when accessing /dashboard, not to /onboarding', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.path).not.toContain('/onboarding')
|
||||
})
|
||||
|
||||
it('redirects to /login when accessing /dashboard/apps', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard/apps')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('redirects to /login when accessing /dashboard/settings', async () => {
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard/settings')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding routes are accessible when authenticated', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/intro', async () => {
|
||||
await router.push('/onboarding/intro')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/intro')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-intro')
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/options', async () => {
|
||||
await router.push('/onboarding/options')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/options')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-options')
|
||||
})
|
||||
|
||||
it('allows access to /onboarding/path', async () => {
|
||||
await router.push('/onboarding/path')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/path')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OnboardingDid route', () => {
|
||||
it('is accessible when authenticated', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
})
|
||||
|
||||
it('is accessible when unauthenticated (public route)', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
})
|
||||
})
|
||||
|
||||
describe('post-onboarding navigation to dashboard', () => {
|
||||
it('allows authenticated users to navigate from onboarding to /dashboard', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
// Start at onboarding done page
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/done')
|
||||
|
||||
// Navigate to dashboard (simulating post-onboarding completion)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows authenticated users to navigate from onboarding/did to /dashboard', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/did')
|
||||
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows navigation through the full onboarding sequence', async () => {
|
||||
mockStore.isAuthenticated = true
|
||||
|
||||
await router.push('/onboarding/intro')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-intro')
|
||||
|
||||
await router.push('/onboarding/path')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-path')
|
||||
|
||||
await router.push('/onboarding/did')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-did')
|
||||
|
||||
await router.push('/onboarding/identity')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-identity')
|
||||
|
||||
await router.push('/onboarding/backup')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-backup')
|
||||
|
||||
await router.push('/onboarding/verify')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-verify')
|
||||
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.name).toBe('onboarding-done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard guard blocks unauthenticated access after onboarding', () => {
|
||||
it('prevents unauthenticated navigation from onboarding/done to /dashboard', async () => {
|
||||
// Start at onboarding done (public route, accessible without auth)
|
||||
await router.push('/onboarding/done')
|
||||
expect(router.currentRoute.value.path).toBe('/onboarding/done')
|
||||
|
||||
// Try to navigate to dashboard without auth
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('prevents unauthenticated access to /dashboard even with stale session', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.needsSessionValidation.mockReturnValue(false)
|
||||
mockStore.checkSession.mockResolvedValue(false)
|
||||
|
||||
await router.push('/dashboard/apps')
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
|
||||
it('allows dashboard access when session check succeeds', async () => {
|
||||
mockStore.isAuthenticated = false
|
||||
mockStore.checkSession.mockResolvedValue(true)
|
||||
|
||||
await router.push('/dashboard')
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { stopAllAudio } from '../composables/useLoginSounds'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -61,6 +62,18 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/recovery',
|
||||
name: 'recovery',
|
||||
component: () => import('../views/KioskRecovery.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/kiosk',
|
||||
name: 'kiosk',
|
||||
component: () => import('../views/Kiosk.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
@@ -110,16 +123,36 @@ const router = createRouter({
|
||||
name: 'server',
|
||||
component: () => import('../views/Server.vue'),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
name: 'monitoring',
|
||||
component: () => import('../views/Monitoring.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/federation',
|
||||
name: 'federation',
|
||||
component: () => import('../views/Federation.vue'),
|
||||
},
|
||||
{
|
||||
path: 'web5',
|
||||
name: 'web5',
|
||||
component: () => import('../views/Web5.vue'),
|
||||
},
|
||||
{
|
||||
path: 'web5/credentials',
|
||||
name: 'credentials',
|
||||
component: () => import('../views/Credentials.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/update',
|
||||
name: 'system-update',
|
||||
component: () => import('../views/SystemUpdate.vue'),
|
||||
},
|
||||
{
|
||||
path: 'goals/:goalId',
|
||||
name: 'goal-detail',
|
||||
@@ -216,13 +249,20 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// Validated and authenticated - ensure WebSocket is connected
|
||||
if (!store.isConnected && !store.isReconnecting) {
|
||||
store.connectWebSocket().catch((err) => {
|
||||
console.warn('[Router] WebSocket connection failed:', err)
|
||||
if (import.meta.env.DEV) console.warn('[Router] WebSocket connection failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// Stop all login/splash audio when entering the dashboard
|
||||
router.afterEach((to, from) => {
|
||||
if (to.path.startsWith('/dashboard') && !from.path.startsWith('/dashboard')) {
|
||||
stopAllAudio()
|
||||
}
|
||||
})
|
||||
|
||||
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
|
||||
router.afterEach((to) => {
|
||||
if (to.path === '/dashboard' || to.path === '/dashboard/') {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,19 @@ export interface DataModel {
|
||||
'server-info': ServerInfo
|
||||
'package-data': { [id: string]: PackageDataEntry }
|
||||
'peer-health'?: { [onion: string]: boolean }
|
||||
notifications?: AppNotification[]
|
||||
ui: UIData
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
level: 'info' | 'warning' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
timestamp: string
|
||||
app_id?: string
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string
|
||||
version: string
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fetchGitHubAppInfo, fetchMultipleAppInfo } from '../githubAppInfo'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
describe('fetchGitHubAppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns empty object for invalid repo URL', async () => {
|
||||
const result = await fetchGitHubAppInfo('not-a-url', 'test')
|
||||
expect(result).toEqual({})
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty object when repo API returns non-OK', async () => {
|
||||
// Start9 repo check
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Original repo fetch fails
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('fetches repo info successfully', async () => {
|
||||
// Start9 wrapper check — not found
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Repo API call
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
description: 'A Bitcoin node',
|
||||
homepage: 'https://bitcoin.org',
|
||||
html_url: 'https://github.com/owner/repo',
|
||||
}),
|
||||
})
|
||||
// README fetch
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: btoa('# README') }),
|
||||
})
|
||||
// Icon path checks — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
// Releases check — no icon
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Raw icon URL HEAD checks — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result.description).toBe('A Bitcoin node')
|
||||
expect(result.readme).toBe('# README')
|
||||
expect(result.homepage).toBe('https://bitcoin.org')
|
||||
})
|
||||
|
||||
it('finds icon from repository contents', async () => {
|
||||
// Start9 wrapper — not found
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Repo API
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ description: '', html_url: 'https://github.com/o/r' }),
|
||||
})
|
||||
// README
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// First icon path (icon.png) — found!
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ download_url: 'https://raw.github.com/o/r/main/icon.png' }),
|
||||
})
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/o/r', 'test')
|
||||
expect(result.icon).toBe('https://raw.github.com/o/r/main/icon.png')
|
||||
})
|
||||
|
||||
it('tries Start9Labs wrapper repo first', async () => {
|
||||
// Start9 wrapper check — found!
|
||||
mockFetch.mockResolvedValueOnce({ ok: true })
|
||||
// Now fetches Start9Labs/app-startos repo
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
description: 'Start9 wrapper',
|
||||
html_url: 'https://github.com/Start9Labs/bitcoin-startos',
|
||||
}),
|
||||
})
|
||||
// README
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Icon paths — all fail
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
// Releases
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
// Raw URLs
|
||||
for (let i = 0; i < 6; i++) {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const result = await fetchGitHubAppInfo('https://github.com/bitcoin/bitcoin', 'bitcoin')
|
||||
expect(result.description).toBe('Start9 wrapper')
|
||||
})
|
||||
|
||||
it('handles fetch errors gracefully', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
const result = await fetchGitHubAppInfo('https://github.com/owner/repo', 'app')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMultipleAppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('returns empty record for empty input', async () => {
|
||||
const result = await fetchMultipleAppInfo([])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('fetches info for multiple apps', async () => {
|
||||
// Each app triggers multiple fetch calls, but they all return non-matching URLs
|
||||
mockFetch.mockResolvedValue({ ok: false })
|
||||
|
||||
const apps = [
|
||||
{ id: 'app1', 'wrapper-repo': 'not-a-github-url' },
|
||||
{ id: 'app2', 'wrapper-repo': 'also-invalid' },
|
||||
]
|
||||
const result = await fetchMultipleAppInfo(apps)
|
||||
expect(result.app1).toEqual({})
|
||||
expect(result.app2).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
|
||||
// Extract owner and repo from URL
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/)
|
||||
if (!match) {
|
||||
console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
|
||||
if (import.meta.env.DEV) console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
|
||||
const repoResponse = await fetch(repoApiUrl)
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
|
||||
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
|
||||
readme = atob(readmeData.content) // Base64 decode
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
|
||||
if (import.meta.env.DEV) console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
|
||||
}
|
||||
|
||||
// Try to find icon in repository
|
||||
@@ -144,7 +144,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
|
||||
homepage: repoData.homepage || repoData.html_url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
|
||||
if (import.meta.env.DEV) console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,7 +784,14 @@ function launchApp() {
|
||||
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
|
||||
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' },
|
||||
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
|
||||
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
|
||||
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
|
||||
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
|
||||
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
|
||||
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' }
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
|
||||
+84
-18
@@ -69,8 +69,9 @@
|
||||
@click="goToApp(id as string)"
|
||||
@keydown.enter="goToApp(id as string)"
|
||||
>
|
||||
<!-- Uninstall Icon -->
|
||||
<!-- Uninstall Icon (not for web-only apps) -->
|
||||
<button
|
||||
v-if="!isWebOnlyApp(id as string)"
|
||||
@click.stop="showUninstallModal(id as string, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||
@@ -120,7 +121,7 @@
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||
@click.stop="startApp(id as string)"
|
||||
:disabled="loadingActions[id as string]"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -139,7 +140,7 @@
|
||||
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running' || pkg.state === 'starting'"
|
||||
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="stopApp(id as string)"
|
||||
:disabled="loadingActions[id as string]"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -251,19 +252,81 @@ function showActionError(msg: string) {
|
||||
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
|
||||
}
|
||||
|
||||
// Use real packages from store - no more dummy apps
|
||||
// Web-only app IDs and their URLs
|
||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
|
||||
function isWebOnlyApp(id: string): boolean {
|
||||
return id in WEB_ONLY_APP_URLS
|
||||
}
|
||||
|
||||
// Web-only apps (no container) — always show as installed bookmarks
|
||||
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
'indeedhub': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeedhub.png' },
|
||||
},
|
||||
'botfights': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/botfights.svg' },
|
||||
},
|
||||
'nwnn': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
|
||||
},
|
||||
'484-kitchen': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
|
||||
},
|
||||
'call-the-operator': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||
},
|
||||
'arch-presentation': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||
},
|
||||
'syntropy-institute': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
|
||||
},
|
||||
't-zero': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
|
||||
},
|
||||
}
|
||||
|
||||
// Merge real packages from store with web-only app bookmarks
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages
|
||||
if (import.meta.env.DEV) console.log('[Apps] Real packages from store:', Object.keys(realPackages || {}).length, 'apps')
|
||||
return realPackages || {}
|
||||
const realPackages = store.packages || {}
|
||||
return { ...WEB_ONLY_APPS, ...realPackages }
|
||||
})
|
||||
|
||||
// Sorted by manifest title, case-insensitive; order stable regardless of running/stopped
|
||||
// Web-only apps first (alphabetically), then all other apps (alphabetically)
|
||||
const sortedPackageEntries = computed(() => {
|
||||
const entries = Object.entries(packages.value)
|
||||
return entries.sort(([, a], [, b]) =>
|
||||
(a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
|
||||
)
|
||||
return entries.sort(([idA, a], [idB, b]) => {
|
||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
||||
if (aWeb !== bWeb) return aWeb - bWeb
|
||||
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPackageEntries = computed(() => {
|
||||
@@ -295,10 +358,10 @@ useModalKeyboard(
|
||||
)
|
||||
|
||||
function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
// For dummy apps, allow launch if running (they have interface addresses)
|
||||
// Web-only apps are always launchable
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
// For real apps, check for UI interface
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
||||
// Allow launch when running or starting (so buttons show even while backend reports "starting")
|
||||
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
|
||||
return !!hasUI && canLaunchState
|
||||
}
|
||||
@@ -306,7 +369,14 @@ function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
function launchApp(id: string) {
|
||||
const isDev = import.meta.env.DEV
|
||||
const pkg = packages.value[id]
|
||||
|
||||
|
||||
// Web-only apps — use their external URL directly
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit URLs for apps that need them (checked first to avoid package data issues)
|
||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
||||
'lorabell': {
|
||||
@@ -321,10 +391,6 @@ function launchApp(id: string) {
|
||||
dev: 'http://localhost:8103',
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
'indeedhub': {
|
||||
dev: 'https://archipelago.indeehub.studio',
|
||||
prod: 'https://archipelago.indeehub.studio'
|
||||
}
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading indicator while iframe loads -->
|
||||
<!-- Loading indicator while checking availability or iframe loads -->
|
||||
<Transition name="fade">
|
||||
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
|
||||
<div v-if="aiuiAvailable === null || (aiuiUrl && !aiuiConnected)" class="chat-loading" role="status" aria-live="polite">
|
||||
<div class="glass-card p-8 flex flex-col items-center gap-4">
|
||||
<div class="chat-loading-spinner" aria-hidden="true" />
|
||||
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
|
||||
<p class="text-sm text-white/60">{{ aiuiAvailable === null ? t('chat.loadingAssistant') : t('chat.loadingAssistant') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -78,13 +78,35 @@ const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false = result
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true'
|
||||
// In production, only return the URL if we've confirmed AIUI files exist
|
||||
if (import.meta.env.PROD && aiuiAvailable.value === true) return '/aiui/?embedded=true'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** Check if AIUI is actually deployed by fetching its index.html */
|
||||
async function checkAiuiAvailable() {
|
||||
if (import.meta.env.VITE_AIUI_URL) {
|
||||
aiuiAvailable.value = true
|
||||
return
|
||||
}
|
||||
if (!import.meta.env.PROD) {
|
||||
aiuiAvailable.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/aiui/', { method: 'HEAD' })
|
||||
// If we get HTML back (200), AIUI is deployed. If 404/403, it's not.
|
||||
aiuiAvailable.value = res.ok
|
||||
} catch {
|
||||
aiuiAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
@@ -106,8 +128,9 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
window.addEventListener('message', onAiuiMessage)
|
||||
await checkAiuiAvailable()
|
||||
if (aiuiUrl.value) {
|
||||
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
|
||||
broker.start()
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
<span>Chat</span>
|
||||
<span>AIUI</span>
|
||||
</button>
|
||||
|
||||
<!-- Logout - styled as nav item, below Settings -->
|
||||
@@ -336,7 +336,7 @@
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
<span class="text-[10px] leading-tight">Chat</span>
|
||||
<span class="text-[10px] leading-tight">AIUI</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
|
||||
<!-- Kiosk launcher grid -->
|
||||
<div class="kiosk-launcher">
|
||||
<!-- Header -->
|
||||
<div class="kiosk-header">
|
||||
<div class="flex items-center gap-4">
|
||||
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
|
||||
<p class="text-sm text-white/50">{{ currentTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="kiosk-status-pill" :class="isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'">
|
||||
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App grid -->
|
||||
<div class="kiosk-grid">
|
||||
<button
|
||||
v-for="app in launchableApps"
|
||||
:key="app.id"
|
||||
class="kiosk-app-tile"
|
||||
@click="openApp(app)"
|
||||
:data-controller-focusable="true"
|
||||
>
|
||||
<div class="kiosk-app-icon-wrap">
|
||||
<img
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="kiosk-app-icon"
|
||||
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
|
||||
/>
|
||||
<div
|
||||
class="kiosk-app-status"
|
||||
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
|
||||
/>
|
||||
</div>
|
||||
<span class="kiosk-app-label">{{ app.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="kiosk-footer">
|
||||
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const kioskRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
interface KioskApp {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
url: string
|
||||
running: boolean
|
||||
}
|
||||
|
||||
// Public asset path — construct with BASE_URL to avoid Vite resolving it as a module import
|
||||
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
|
||||
|
||||
const currentTime = ref('')
|
||||
|
||||
const isConnected = computed(() => store.isConnected)
|
||||
|
||||
// Build list of launchable apps from the store's package data
|
||||
const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
const apps: KioskApp[] = []
|
||||
|
||||
// App URL mappings — use nginx proxy paths for local apps
|
||||
const urlMap: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'grafana': '/app/grafana/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'immich': '/app/immich/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'searxng': '/app/searxng/',
|
||||
'ollama': '/app/ollama/',
|
||||
'penpot': '/app/penpot/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'dwn': '/app/dwn/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
|
||||
for (const [id, pkg] of Object.entries(pkgs)) {
|
||||
const url = urlMap[id]
|
||||
if (!url) continue
|
||||
|
||||
const isRunning = pkg.state === 'running' ||
|
||||
pkg.installed?.status === 'running'
|
||||
|
||||
apps.push({
|
||||
id,
|
||||
title: pkg.manifest?.title || id,
|
||||
icon: pkg['static-files']?.icon || FALLBACK_ICON,
|
||||
url,
|
||||
running: isRunning,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort: running apps first, then alphabetical
|
||||
return apps.sort((a, b) => {
|
||||
if (a.running !== b.running) return a.running ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
})
|
||||
|
||||
function openApp(app: KioskApp) {
|
||||
// Delegate to the app launcher — handles iframe overlay vs new-tab
|
||||
appLauncher.open({ url: app.url, title: app.title })
|
||||
}
|
||||
|
||||
// Clock updater
|
||||
let clockInterval: ReturnType<typeof setInterval> | undefined
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 30000)
|
||||
kioskRoot.value?.focus()
|
||||
|
||||
// Connect WebSocket if not already
|
||||
if (!store.isConnected) {
|
||||
store.connectWebSocket().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kiosk-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.kiosk-launcher {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 3rem;
|
||||
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
|
||||
}
|
||||
|
||||
.kiosk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.kiosk-status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kiosk-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.kiosk-app-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kiosk-app-tile:hover,
|
||||
.kiosk-app-tile:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kiosk-app-icon-wrap {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.kiosk-app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kiosk-app-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #000;
|
||||
}
|
||||
|
||||
.kiosk-app-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kiosk-footer {
|
||||
padding-top: 1.5rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -397,6 +397,7 @@ const categories = computed(() => [
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'car', name: t('marketplace.auto') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'l484', name: 'L484' },
|
||||
{ id: 'other', name: t('marketplace.other') }
|
||||
])
|
||||
|
||||
@@ -522,11 +523,14 @@ const installedPackages = computed(() => {
|
||||
|
||||
// Function to categorize community apps based on their ID and description
|
||||
function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
// If app already has a category set, use it
|
||||
if (app.category) return app.category
|
||||
|
||||
const id = app.id.toLowerCase()
|
||||
const title = app.title?.toLowerCase() || ''
|
||||
const description = (typeof app.description === 'string' ? app.description : app.description?.short ?? '').toLowerCase()
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
|
||||
// Money category
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') ||
|
||||
id.includes('lnd') || id.includes('cln') || id.includes('electr') ||
|
||||
@@ -949,6 +953,96 @@ function getCuratedAppList() {
|
||||
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:0.9.0',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
|
||||
},
|
||||
{
|
||||
id: 'botfights',
|
||||
title: 'BotFights',
|
||||
version: '1.0.0',
|
||||
description: 'AI bot arena — build, train, and battle autonomous agents. Compete in strategy tournaments with your own coded bots.',
|
||||
icon: '/assets/img/app-icons/botfights.svg',
|
||||
author: 'BotFights',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://botfights.net',
|
||||
webUrl: 'https://botfights.net'
|
||||
},
|
||||
{
|
||||
id: 'nwnn',
|
||||
title: 'Next Web News Network',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'Decentralized news and link aggregator, synchronized from Telegram. Community-curated content on Bitcoin, sovereignty, and decentralized tech.',
|
||||
icon: '/assets/img/app-icons/nwnn.png',
|
||||
author: 'L484',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://nwnn.l484.com',
|
||||
webUrl: 'https://nwnn.l484.com'
|
||||
},
|
||||
{
|
||||
id: '484-kitchen',
|
||||
title: '484 Kitchen',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'K484 application platform — an internal tool for the L484 network.',
|
||||
icon: '/assets/img/app-icons/484-kitchen.png',
|
||||
author: 'L484',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://484.kitchen',
|
||||
webUrl: 'https://484.kitchen'
|
||||
},
|
||||
{
|
||||
id: 'call-the-operator',
|
||||
title: 'Call the Operator',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'Escape the Matrix — a portal for exploring decentralized alternatives and reclaiming digital sovereignty.',
|
||||
icon: '/assets/img/app-icons/call-the-operator.png',
|
||||
author: 'TX1138',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://cta.tx1138.com',
|
||||
webUrl: 'https://cta.tx1138.com'
|
||||
},
|
||||
{
|
||||
id: 'arch-presentation',
|
||||
title: 'Arch Presentation',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'Archipelago: The Future of Decentralized Infrastructure — an interactive presentation about the Archipelago project vision.',
|
||||
icon: '/assets/img/app-icons/arch-presentation.png',
|
||||
author: 'L484',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://present.l484.com',
|
||||
webUrl: 'https://present.l484.com'
|
||||
},
|
||||
{
|
||||
id: 'syntropy-institute',
|
||||
title: 'Syntropy Institute',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency analysis-therapy, digital homeopathy, and concierge protocols.',
|
||||
icon: '/assets/img/app-icons/syntropy-institute.png',
|
||||
author: 'Syntropy Institute',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://syntropy.institute',
|
||||
webUrl: 'https://syntropy.institute'
|
||||
},
|
||||
{
|
||||
id: 't-zero',
|
||||
title: 'T-0',
|
||||
version: '1.0.0',
|
||||
category: 'l484',
|
||||
description: 'Documentary series exploring decentralization, Bitcoin, and the mavericks building the ungovernable future. Conversations with the builders, powered by Nostr.',
|
||||
icon: '/assets/img/app-icons/t-zero.png',
|
||||
author: 'T-0',
|
||||
dockerImage: '',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://teeminuszero.net',
|
||||
webUrl: 'https://teeminuszero.net'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -374,6 +374,7 @@ import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
@@ -391,8 +392,14 @@ const loading = ref(true)
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Web-only apps (no container, just a URL) — always treated as "installed"
|
||||
const isWebOnly = computed(() => {
|
||||
return !!(app.value?.webUrl && !app.value?.dockerImage)
|
||||
})
|
||||
|
||||
// Check if app is already installed
|
||||
const isInstalled = computed(() => {
|
||||
if (isWebOnly.value) return true
|
||||
return !!store.packages[appId.value]
|
||||
})
|
||||
|
||||
@@ -503,6 +510,14 @@ function goBack() {
|
||||
}
|
||||
|
||||
function goToInstalledApp() {
|
||||
// Web-only apps: launch directly via appLauncher
|
||||
if (isWebOnly.value && app.value?.webUrl) {
|
||||
useAppLauncherStore().open({
|
||||
url: app.value.webUrl,
|
||||
title: app.value.title || appId.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: `/dashboard/apps/${appId.value}`,
|
||||
query: { from: 'marketplace' }
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, VueWrapper } 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',
|
||||
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 Settings heading', () => {
|
||||
const wrapper = mountSettings()
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.exists()).toBe(true)
|
||||
expect(heading.text()).toBe('Settings')
|
||||
})
|
||||
|
||||
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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user