frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
@@ -0,0 +1,83 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppSession from '../AppSession.vue'
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
mockReplace: vi.fn(),
mockPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockSuppress: vi.fn(),
mockResume: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
params: { appId: 'gitea' },
query: { returnTo: '/dashboard/apps' },
fullPath: '/dashboard/apps/session/gitea',
}),
useRouter: () => ({ replace: mockReplace, push: mockPush }),
}))
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({ panelAppId: null }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ data: { 'package-data': {} } }),
}))
vi.mock('@/stores/screensaver', () => ({
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
}))
vi.mock('../appSession/useAppIdentity', () => ({
useAppIdentity: () => ({
onIdentitySelected: vi.fn(),
onIframeLoadIdentity: vi.fn(),
handleIdentityRequest: vi.fn(),
getStoredIdentity: vi.fn(),
}),
}))
vi.mock('../appSession/useNostrBridge', () => ({
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
}))
vi.stubGlobal('open', mockWindowOpen)
describe('AppSession mobile new-tab apps', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
})
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
const wrapper = mount(AppSession, {
global: {
stubs: {
Teleport: true,
AppSessionHeader: true,
NostrIdentityPicker: true,
MobileGamepad: true,
},
},
})
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('This app opens in a new tab')
expect(wrapper.text()).toContain('Open in new tab')
})
})
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
federationListNodes: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makePeer() {
return {
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer Alpha',
trust_level: 'trusted',
}
}
describe('Cloud peer list', () => {
it('keeps peer nodes visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
const wrapper = mount(Cloud)
await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).not.toContain('No peers yet')
const pending = deferred<{ nodes: [] }>()
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).toContain('Refreshing peer nodes...')
expect(wrapper.text()).not.toContain('No peers yet')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
expect(wrapper.text()).not.toContain('No peers yet')
})
})
@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeCredential(id: string) {
return {
id,
type: ['VerifiableCredential', 'NodeOperator'],
issuer: 'did:key:issuer',
credentialSubject: { id: 'did:key:subject' },
issuanceDate: '2026-06-10T10:00:00Z',
status: 'active',
}
}
describe('Credentials', () => {
it('keeps credentials visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
if (request.method === 'identity.list-credentials') {
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
}
return Promise.resolve({})
})
const wrapper = mount(Credentials, {
global: {
mocks: {
$router: { push: vi.fn() },
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
const pending = deferred<{ credentials: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.list-credentials') return pending.promise
return Promise.resolve({ identities: [] })
})
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
expect(wrapper.text()).toContain('Refreshing credentials...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
expect(wrapper.text()).not.toContain('Refreshing credentials...')
})
})
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OnboardingOptions from '../OnboardingOptions.vue'
const push = vi.fn(() => Promise.resolve())
vi.mock('vue-router', () => ({
useRouter: () => ({ push }),
}))
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
describe('OnboardingOptions', () => {
it('shows only usable setup paths', () => {
const wrapper = mount(OnboardingOptions)
expect(wrapper.text()).toContain('Fresh Start')
expect(wrapper.text()).toContain('Restore from Seed')
expect(wrapper.text()).not.toContain('Connect Existing')
expect(wrapper.text()).not.toContain('Coming Soon')
})
it('continues to fresh seed generation by default', async () => {
push.mockClear()
const wrapper = mount(OnboardingOptions)
await wrapper.get('button.path-action-button').trigger('click')
expect(push).toHaveBeenCalledWith('/onboarding/seed')
})
it('routes restore choice to seed restore', async () => {
push.mockClear()
const wrapper = mount(OnboardingOptions)
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
expect(restoreButton).toBeDefined()
await restoreButton!.trigger('click')
await wrapper.get('button.path-action-button').trigger('click')
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
})
})
@@ -0,0 +1,85 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
federationListNodes: vi.fn(),
},
}))
vi.mock('@/composables/useAudioPlayer', () => ({
useAudioPlayer: () => ({ play: vi.fn() }),
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeCatalogItem() {
return {
id: 'file-1',
filename: 'notes.txt',
mime_type: 'text/plain',
size_bytes: 128,
description: '',
access: 'free',
}
}
describe('PeerFiles', () => {
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
nodes: [{
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer',
trust_level: 'trusted',
}],
} as never)
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' },
global: {
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
const pending = deferred<{ items: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).toContain('Refreshing peer files...')
expect(wrapper.text()).not.toContain('Connecting via Tor')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing peer files...')
})
})
@@ -0,0 +1,216 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Server from '../Server.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
vpnStatus: vi.fn(),
dnsStatus: vi.fn(),
diskStatus: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function mountServer(options: { renderTorServices?: boolean } = {}) {
return mount(Server, {
global: {
stubs: {
QuickActionsCard: true,
TorServicesCard: options.renderTorServices ? false : true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
}
describe('Server network refresh states', () => {
it('keeps network overview visible while refresh is pending', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [{}, {}] })
}
if (request.method === 'network.list-interfaces') {
return Promise.resolve({ interfaces: [] })
}
if (request.method === 'tor.list-services') {
return Promise.resolve({ services: [], tor_running: false })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer()
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
expect(wrapper.text()).toContain('Refreshing network...')
pendingDiagnostics.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
})
it('keeps network interfaces visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.list-interfaces') {
return Promise.resolve({
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
})
}
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: false })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [] })
}
if (request.method === 'tor.list-services') {
return Promise.resolve({ services: [], tor_running: false })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer()
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
const pendingInterfaces = deferred<{ interfaces: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
return Promise.resolve({})
})
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
expect(wrapper.text()).toContain('Refreshing interfaces...')
pendingInterfaces.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
})
it('keeps Tor services visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'tor.list-services') {
return Promise.resolve({
services: [{
name: 'filebrowser',
local_port: 8080,
onion_address: 'filebrowser123456789.onion',
enabled: true,
unauthenticated: false,
protocol: false,
}],
tor_running: true,
})
}
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: true })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [] })
}
if (request.method === 'network.list-interfaces') {
return Promise.resolve({ interfaces: [] })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer({ renderTorServices: true })
await flushPromises()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'tor.list-services') return pendingTor.promise
return Promise.resolve({})
})
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
expect(wrapper.text()).toContain('Refreshing Tor services...')
pendingTor.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
})
})
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
describe('cloudPath helpers', () => {
it('normalizes query paths', () => {
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
})
it('walks to the parent folder without leaving root', () => {
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
expect(parentCloudPath('/Photos')).toBe('/')
expect(parentCloudPath('/')).toBe('/')
})
})