Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,85 @@
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(() => Promise.resolve()),
mockPush: vi.fn(() => Promise.resolve()),
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.0.2.10' },
writable: true,
configurable: true,
})
})
it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
const wrapper = mount(AppSession, {
global: {
stubs: {
Teleport: true,
AppSessionHeader: true,
NostrIdentityPicker: true,
MobileGamepad: true,
},
},
})
await flushPromises()
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
// (no native bridge in the test) and dismiss the empty session — no
// "this app opens in a tab" interstitial.
expect(mockWindowOpen).toHaveBeenCalled()
expect(mockReplace).toHaveBeenCalled()
expect(wrapper.text()).not.toContain('This app opens in a new tab')
})
})
@@ -0,0 +1,72 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
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',
added_at: '2026-06-10T10:00:00Z',
}
}
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, { global: { plugins: [createPinia()] } })
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,79 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
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: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
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,92 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Marketplace from '../Marketplace.vue'
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
// mounting a view directly with its heavier deps mocked at the module
// boundary) — kept in its own file rather than keepAliveTabs.test.ts because
// vi.mock('vue-router', ...) is hoisted file-wide and would otherwise
// clobber that file's real createRouter/createMemoryHistory imports used by
// the DashboardRouterView tests (Rule 3 auto-fix).
const routerPushMock = vi.fn()
const toastErrorMock = vi.fn()
const toastInfoMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPushMock, replace: vi.fn().mockResolvedValue(undefined) }),
useRoute: () => ({ query: {} }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ data: {}, hasLoadedInitialData: true }),
}))
vi.mock('@/stores/server', () => ({
useServerStore: () => ({
installingApps: new Map(),
setInstallProgress: vi.fn(),
clearInstallProgress: vi.fn(),
}),
}))
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({ openSession: vi.fn() }),
}))
vi.mock('@/composables/useMarketplaceApp', () => ({
useMarketplaceApp: () => ({ setCurrentApp: vi.fn() }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
},
}))
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear()
toastErrorMock.mockClear()
toastInfoMock.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps the curated catalog on screen and raises no toast when the prune-status background refresh rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ blockchain_info: { pruned: false } }),
}))
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()] } })
await flushPromises()
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
vi.mocked(fetch).mockRejectedValueOnce(new Error('node unreachable'))
await (wrapper.vm as unknown as { loadBitcoinPruneStatus: () => Promise<void> }).loadBitcoinPruneStatus()
await flushPromises()
// Prior catalog content is still rendered — a background refresh failure
// on an unrelated resource never wipes the view.
expect(wrapper.findAll('.glass-card').length).toBeGreaterThan(0)
expect(toastErrorMock).not.toHaveBeenCalled()
expect(toastInfoMock).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -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,180 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OnboardingSeedGenerate from '../OnboardingSeedGenerate.vue'
import { rpcClient } from '@/api/rpc-client'
const WORDS = Array.from({ length: 24 }, (_, i) => `word${i + 1}`)
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(() => Promise.resolve()) }),
}))
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
// Set the scroll region's and confirmation label's geometry directly — jsdom
// has no layout engine, so scrollHeight/clientHeight/scrollTop are normally 0
// and getBoundingClientRect() always returns an all-zero rect, so both must
// be driven explicitly.
function setGeometry(
container: HTMLElement,
label: HTMLElement,
opts: { scrollHeight: number; clientHeight: number; scrollTop: number; containerBottom: number; labelBottom: number },
) {
Object.defineProperty(container, 'scrollHeight', { value: opts.scrollHeight, writable: true, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: opts.clientHeight, writable: true, configurable: true })
Object.defineProperty(container, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true })
container.getBoundingClientRect = () => ({ bottom: opts.containerBottom } as DOMRect)
label.getBoundingClientRect = () => ({ bottom: opts.labelBottom } as DOMRect)
}
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
vi.mocked(rpcClient.call).mockReset()
Element.prototype.scrollIntoView = vi.fn()
})
async function mountWithWords() {
vi.mocked(rpcClient.call).mockResolvedValue({ words: WORDS })
const wrapper = mount(OnboardingSeedGenerate)
await flushPromises()
await wrapper.vm.$nextTick()
return wrapper
}
it('renders no cue when the scroll region reports no overflow', async () => {
const wrapper = await mountWithWords()
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
const label = wrapper.get('label').element as HTMLElement
setGeometry(container, label, {
scrollHeight: 400,
clientHeight: 400,
scrollTop: 0,
containerBottom: 400,
labelBottom: 350,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('One more step below')
})
it('renders the cue when there is overflow and the tickbox is below the fold', async () => {
const wrapper = await mountWithWords()
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
const label = wrapper.get('label').element as HTMLElement
setGeometry(container, label, {
scrollHeight: 800,
clientHeight: 400,
scrollTop: 0,
containerBottom: 400,
labelBottom: 750,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('One more step below')
})
it('removes the cue once scrolling brings the tickbox into view', async () => {
const wrapper = await mountWithWords()
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
const label = wrapper.get('label').element as HTMLElement
setGeometry(container, label, {
scrollHeight: 800,
clientHeight: 400,
scrollTop: 0,
containerBottom: 400,
labelBottom: 750,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('One more step below')
// Scroll down: the container's own viewport rect doesn't move, but its
// scrolled content does — the label's viewport-relative bottom shifts up
// by the scroll delta, bringing it inside the visible window.
setGeometry(container, label, {
scrollHeight: 800,
clientHeight: 400,
scrollTop: 400,
containerBottom: 400,
labelBottom: 350,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('One more step below')
})
it('activating the cue scrolls the tickbox into view and never touches confirmed', async () => {
const wrapper = await mountWithWords()
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
const label = wrapper.get('label').element as HTMLElement
setGeometry(container, label, {
scrollHeight: 800,
clientHeight: 400,
scrollTop: 0,
containerBottom: 400,
labelBottom: 750,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
const cueButton = wrapper.findAll('button').find((b) => b.text().includes('One more step below'))
expect(cueButton).toBeDefined()
await cueButton!.trigger('click')
expect(label.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
const checkbox = wrapper.get('input[type="checkbox"]').element as HTMLInputElement
expect(checkbox.checked).toBe(false)
})
it('never shows the cue while loading, regardless of overflow', async () => {
let resolveCall: (v: { words: string[] }) => void = () => {}
vi.mocked(rpcClient.call).mockReturnValue(new Promise((resolve) => { resolveCall = resolve }))
const wrapper = mount(OnboardingSeedGenerate)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('One more step below')
expect(wrapper.text()).toContain('Generating your seed phrase')
resolveCall({ words: WORDS })
await flushPromises()
})
it('removes the cue once the tickbox is ticked', async () => {
const wrapper = await mountWithWords()
const container = wrapper.get('.overflow-y-auto').element as HTMLElement
const label = wrapper.get('label').element as HTMLElement
setGeometry(container, label, {
scrollHeight: 800,
clientHeight: 400,
scrollTop: 0,
containerBottom: 400,
labelBottom: 750,
})
await container.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('One more step below')
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('One more step below')
})
})
@@ -0,0 +1,138 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
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: {
// The shared peer-browse cache lives in the Pinia resources store.
plugins: [createPinia()],
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...')
})
it('opens the full-screen lightbox when a FREE image card is clicked', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
nodes: [{
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer',
trust_level: 'trusted',
}],
} as never)
const freeImage = {
id: 'photo-1',
filename: 'sunset.jpg',
mime_type: 'image/jpeg',
size_bytes: 1024,
description: '',
access: 'free',
}
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') return { items: [freeImage] }
if (req.method === 'content.owned-list') return { items: [] }
return {}
}) as never)
const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' },
global: {
plugins: [createPinia()],
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('sunset.jpg')
// The free image card click was previously a no-op (the old ternary fell
// through to `undefined` for non-playable free items).
await wrapper.find('.aspect-video').trigger('click')
await flushPromises()
const viewerImg = wrapper
.findAll('img')
.find(img => (img.attributes('src') || '').includes('/api/peer-content/'))
expect(viewerImg).toBeTruthy()
expect(viewerImg!.attributes('src')).toContain('photo-1')
expect(wrapper.text()).toContain('Free · shared by peer')
})
})
@@ -0,0 +1,219 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
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: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
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.0.2.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.0.2.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.0.2.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.0.2.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,314 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* THE FIPS / TOR TRANSPORT PILLS ARE A PERMANENT, USER-REQUESTED FEATURE.
* ─────────────────────────────────────────────────────────────────────────────
*
* Dorian asked for these pills explicitly ("really helpful") and asked that no
* future cleanup or refactor ever remove them. They are the one place the app
* tells a user whether their file arrived over the fast encrypted mesh (FIPS)
* or over Tor — a security signal, not decoration. Requirement UIFIX-01.
*
* IF A TEST IN THIS FILE FAILS, THE MOST LIKELY CAUSE IS THAT SOMEONE REMOVED
* OR RENAMED A TRANSPORT PILL — not that the test went stale. Put the pill
* back. If a pill genuinely has to move, move the assertion with it; do not
* delete the assertion.
*
* Each `it()` below is keyed to ONE specific render site so that deleting the
* pill from that site, and only that site, fails. A pill somewhere else in the
* app does not satisfy these assertions.
*
* Render sites pinned here (audited 2026-08-02 at 390×740 and 320×640):
* S1 Cloud.vue — peer card badge row, Folders tab (renders a pill)
* S2 Cloud.vue — Peer Files aggregated rows (no pill, by decision)
* S3 Cloud.vue — Paid Files rows (no pill, by decision)
* S4 PeerFiles.vue — header, desktop copy + mobile copy (renders a pill)
* S5 PeerFiles.vue — per-file card body (no pill, by decision)
*
* The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a
* bug: transport is measured PER PEER PER BROWSE, never per file, so a
* per-file pill would claim a reading the app never took. The reasoning is
* If you deliberately add a per-file pill, update that decision record and
* this test together.
*/
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import Cloud from '../Cloud.vue'
import PeerFiles from '../PeerFiles.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('@/composables/useAudioPlayer', () => ({
useAudioPlayer: () => ({ play: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
federationListNodes: vi.fn(),
},
}))
const PEER_ONION = 'peeraaaa1111bbbb2222cccc3333dddd4444eeee.onion'
function makePeer(overrides: Record<string, unknown> = {}) {
return {
did: 'did:key:peerAlpha',
pubkey: 'peer',
onion: PEER_ONION,
name: 'Peer Alpha',
trust_level: 'trusted',
added_at: '2026-06-10T10:00:00Z',
...overrides,
}
}
function makeItem(overrides: Record<string, unknown> = {}) {
return {
id: 'file-1',
filename: 'notes.txt',
mime_type: 'text/plain',
size_bytes: 128,
description: '',
access: 'free',
...overrides,
}
}
/** Mount Cloud.vue with one peer whose last browse resolved over `transport`.
* Pass `transport: null` for the "we have not observed a transport" edge. */
async function mountCloud(transport: string | null, peerOverrides: Record<string, unknown> = {}) {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer(peerOverrides)] } as never)
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') {
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
}
if (req.method === 'content.owned-list') {
return {
items: [{
onion: PEER_ONION,
content_id: 'file-1',
filename: 'paid-track.mp3',
mime_type: 'audio/mpeg',
size_bytes: 4096,
paid_sats: 2500,
purchased_at: '2026-07-30T10:00:00Z',
}],
}
}
return {}
}) as never)
const wrapper = mount(Cloud, {
global: { plugins: [createPinia()], stubs: { Teleport: true } },
})
await flushPromises()
await flushPromises()
return wrapper
}
async function mountPeerFiles(transport: string | null) {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [makePeer()] } as never)
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') {
return transport === null ? { items: [makeItem()] } : { items: [makeItem()], transport }
}
if (req.method === 'content.owned-list') return { items: [] }
return {}
}) as never)
const wrapper = mount(PeerFiles, {
props: { peerId: PEER_ONION },
global: { plugins: [createPinia()], stubs: { Teleport: true } },
})
await flushPromises()
await flushPromises()
return wrapper
}
/** The Cloud.vue peer-card badge pill renders "<TRANSPORT> · <n>s" — a shape
* no other pill in either view produces, which is what keys these assertions
* to site S1 specifically. */
function cloudPeerCardPill(wrapper: ReturnType<typeof mount>) {
return wrapper.findAll('span').find(s => /^(FIPS|TOR|MESH|LAN)\s·\s[\d.]+s$/.test(s.text().trim()))
}
beforeEach(() => {
vi.clearAllMocks()
// The peer-browse cache is a persist:true key — it snapshots into
// sessionStorage, which outlives a per-test `createPinia()`. Without this,
// the transport from an earlier test leaks into the next one and the
// unknown-transport case would "pass" against a stale FIPS reading.
sessionStorage.clear()
})
// ── S1: Cloud.vue peer card badge row ───────────────────────────────────────
describe('S1 — Cloud.vue peer card transport pill (Folders tab)', () => {
it('renders the FIPS pill on the peer card when the last browse used FIPS', async () => {
const wrapper = await mountCloud('fips')
const pill = cloudPeerCardPill(wrapper)
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
expect(pill!.text()).toMatch(/^FIPS · [\d.]+s$/)
// FIPS reads as the good/fast path — emerald, matching the canonical palette.
expect(pill!.classes().join(' ')).toContain('bg-emerald-500/15')
expect(pill!.attributes('title')).toContain('FIPS')
})
it('renders the TOR pill, in the slow-path colour, when the last browse used Tor', async () => {
const wrapper = await mountCloud('tor')
const pill = cloudPeerCardPill(wrapper)
expect(pill, 'peer card transport pill is missing — see the header of this file').toBeTruthy()
expect(pill!.text()).toMatch(/^TOR · [\d.]+s$/)
// Tor is the slow fallback — amber, visibly different from FIPS.
expect(pill!.classes().join(' ')).toContain('bg-amber-500/15')
expect(pill!.attributes('title')).toContain('TOR')
})
it('fabricates no pill when no transport has been observed, and keeps the not-known treatment', async () => {
const wrapper = await mountCloud(null)
// T-01-78: never claim a transport the app has not actually measured.
expect(cloudPeerCardPill(wrapper)).toBeUndefined()
expect(wrapper.text()).not.toMatch(/\bFIPS\b/)
// The existing "we don't know yet" treatment stays.
expect(wrapper.text()).toContain('Peer Node')
})
// UIFIX-01 mobile half. The badge row has no horizontal give at 320px: with a
// longer trust label and no wrapping, flexbox compresses the transport badge
// until its OWN text breaks mid-label ("TOR ·" / "120.0s") — measured in a
// real browser at 320×640. flex-wrap makes the badge drop to a second line
// intact instead, and shrink-0 stops it being squeezed on the way there.
it('lets the badge row wrap and keeps the transport pill unsqueezed (mobile legibility)', async () => {
const wrapper = await mountCloud('tor')
const pill = cloudPeerCardPill(wrapper)
expect(pill).toBeTruthy()
expect(pill!.classes(), 'transport pill must not be compressible').toContain('shrink-0')
const row = pill!.element.parentElement as HTMLElement
expect(row, 'transport pill has no parent row').toBeTruthy()
expect(
Array.from(row.classList),
'the peer-card badge row must wrap, or the pill text breaks mid-label at 320px',
).toContain('flex-wrap')
})
})
// ── S2/S3: Cloud.vue file lists carry no per-file transport pill (decision) ──
describe('S2/S3 — Cloud.vue file rows carry no per-file transport pill (recorded decision)', () => {
it('shows no transport pill on the aggregated Peer Files rows or the Paid Files rows', async () => {
const wrapper = await mountCloud('fips')
const vm = wrapper.vm as unknown as { activeTab: string }
for (const tab of ['peers', 'paid']) {
vm.activeTab = tab
await flushPromises()
// Transport is a per-peer, per-browse reading. These rows list files —
// from many peers at once in the Peer Files case, and from the local
// purchase cache (no live transport at all) in the Paid Files case.
// A pill here would assert a per-file measurement that was never taken.
expect(
cloudPeerCardPill(wrapper),
`a transport pill appeared on the "${tab}" rows — see the header of this file`,
).toBeUndefined()
expect(wrapper.text(), `"${tab}" rows must not label files with a transport`).not.toMatch(/\bFIPS\b/)
}
})
})
// ── S4: PeerFiles.vue header, desktop copy AND mobile copy ──────────────────
describe('S4 — PeerFiles.vue header transport pill', () => {
it('renders the pill in the desktop title block', async () => {
const wrapper = await mountPeerFiles('fips')
const desktopBlock = wrapper.findAll('div').find(d => {
const c = d.classes()
return c.includes('hidden') && c.includes('md:block')
})
expect(desktopBlock, 'PeerFiles desktop title block is missing').toBeTruthy()
const pill = desktopBlock!.findAll('span').find(s => s.text().trim() === 'FIPS')
expect(pill, 'desktop header transport pill is missing — see the header of this file').toBeTruthy()
// Canonical mapping (PeerFiles.vue transportPill), not a duplicated table.
expect(pill!.classes().join(' ')).toContain('bg-green-500/20')
expect(pill!.attributes('title')).toContain('FIPS')
})
it('renders a separate mobile copy of the pill, because the desktop title block is hidden on a phone', async () => {
const wrapper = await mountPeerFiles('fips')
// This is the UIFIX-01 mobile half for this site: the title block that
// carries the desktop pill is `hidden md:block`, so without this copy a
// phone user would see no transport at all on the peer's file page.
const mobilePill = wrapper.findAll('span').find(s =>
s.classes().includes('md:hidden') && s.text().trim() === 'FIPS',
)
expect(
mobilePill,
'the md:hidden mobile transport pill is missing — a phone would show no transport here',
).toBeTruthy()
expect(mobilePill!.classes().join(' ')).toContain('bg-green-500/20')
})
it.each([
['fips', 'FIPS', 'bg-green-500/20'],
['mesh', 'Mesh', 'bg-green-500/20'],
['lan', 'LAN', 'bg-blue-500/20'],
['tor', 'Tor', 'bg-amber-500/20'],
])('maps transport %s to the canonical label %s and its canonical colour', async (transport, label, colour) => {
const wrapper = await mountPeerFiles(transport)
const pills = wrapper.findAll('span').filter(s => s.text().trim() === label)
// One desktop copy + one mobile copy, both from the same canonical mapping.
expect(pills.length, `expected the ${label} pill in both the desktop and mobile header copies`).toBe(2)
for (const p of pills) expect(p.classes().join(' ')).toContain(colour)
})
it('fabricates no pill for an unobserved transport, in either the desktop or the mobile copy', async () => {
const wrapper = await mountPeerFiles(null)
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
expect(
wrapper.findAll('span').some(s => s.text().trim() === label),
`PeerFiles fabricated a "${label}" pill with no observed transport`,
).toBe(false)
}
// The file list itself is unaffected — only the transport claim is absent.
expect(wrapper.text()).toContain('notes.txt')
})
})
// ── S5: PeerFiles.vue per-file cards carry no transport pill (decision) ─────
describe('S5 — PeerFiles.vue per-file cards carry no transport pill (recorded decision)', () => {
it('labels file cards with access only, leaving transport to the single peer-level pill', async () => {
const wrapper = await mountPeerFiles('tor')
// Every file on this page came from the same peer over the same transport,
// so the header pill already states it once. Repeating it per card would
// add no information and would crowd the row at 320px.
const cardBody = wrapper.findAll('div').find(d => {
const c = d.classes()
return c.includes('p-4') && c.includes('flex') && c.includes('mt-auto')
})
expect(cardBody, 'PeerFiles per-file card body is missing').toBeTruthy()
for (const label of ['FIPS', 'Mesh', 'LAN', 'Tor']) {
expect(
cardBody!.findAll('span').some(s => s.text().trim() === label),
`a transport pill appeared on a per-file card — see the header of this file`,
).toBe(false)
}
})
})
@@ -0,0 +1,242 @@
// 02-07: the AIUI embed URL must be byte-stable across re-renders, a
// simulated viewport resize, and a KeepAlive deactivate/reactivate cycle —
// any runtime-varying input would change the iframe `src` and force a full
// AIUI reload on the next tab switch, giving back the entire benefit 02-04
// established for the Chat tab. Also covers the two D-14 presentation
// flags (chatExpanded, mobileChat), origin validation being unchanged, and
// aiuiConnected surviving deactivation (AIUI's 'ready' message is not
// re-sent on re-entry).
import { flushPromises, mount } from '@vue/test-utils'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue'
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
const routerReplaceMock = vi.fn()
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
// handoff, and route.path when it strips those params back off. Kept empty by
// default so the byte-stability assertions below see no ask in play.
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
useRoute: () => routeMock,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
// IS_DEMO is a build-time constant in the real module; mock it so these
// tests exercise the plain VITE_AIUI_URL branch deterministically.
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false }))
// ContextBroker pulls in several Pinia stores (app/container/aiPermissions)
// unrelated to this test's concern (URL stability + origin validation) —
// mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's
// convention for isolating a view from its heavier dependencies.
vi.mock('@/services/contextBroker', () => ({
ContextBroker: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}))
/** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */
function mountChatInKeepAlive() {
const show = ref(true)
const Host = defineComponent({
setup() {
return () => h(KeepAlive, null, {
default: () => (show.value ? h(Chat) : h('div', 'other-tab')),
})
},
})
const wrapper = mount(Host)
return { wrapper, show }
}
function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
return wrapper.find('iframe').attributes('src')
}
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
beforeEach(() => {
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
})
afterEach(() => {
vi.unstubAllEnvs()
routeMock.query = {}
routerReplaceMock.mockClear()
})
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
const { wrapper } = mountChatInKeepAlive()
const src = iframeSrc(wrapper)
expect(src).toBeTruthy()
expect(src).toContain('embedded=true')
expect(src).toContain('hideClose=true')
expect(src).toContain('chatExpanded=true')
expect(src).toContain('mobileChat=true')
wrapper.unmount()
})
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
// by postMessage: putting it in the URL would give aiuiUrl a reactive
// dependency and reload AIUI on every question, which is precisely the
// byte-stability property the rest of this file exists to protect.
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
expect(before).not.toContain('ask=')
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).toHaveBeenCalledWith(
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
'http://localhost:5173',
)
// src must be byte-identical after the ask round-trip
expect(iframeSrc(wrapper)).toBe(before)
// and the params are stripped so a refresh does not silently re-ask
expect(routerReplaceMock).toHaveBeenCalled()
const replaceArg = routerReplaceMock.mock.calls[0]![0]
expect(replaceArg.query.ask).toBeUndefined()
expect(replaceArg.query.askedAt).toBeUndefined()
wrapper.unmount()
})
it('does not post a prefill when there is no ask in the route', async () => {
const { wrapper } = mountChatInKeepAlive()
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).not.toHaveBeenCalled()
wrapper.unmount()
})
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 })
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('is string-equal before and after a deactivate/reactivate cycle', async () => {
const { wrapper, show } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('does not set aiuiConnected for a message from a foreign origin', async () => {
const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://evil.example',
}))
await flushPromises()
// aiuiConnected stays false: the loading overlay is still shown and the
// connected indicator (title="chat.aiuiConnected") is absent.
expect(wrapper.find('.chat-loading').exists()).toBe(true)
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
})
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://localhost:5173',
}))
await flushPromises()
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
await flushPromises()
// No second 'ready' message is sent on reactivation — aiuiConnected must
// not have been reset to false by the deactivate/reactivate cycle.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
wrapper.unmount()
})
// Belt-and-suspenders backstop added after live testing found the overlay
// could wedge the UI when the 'ready' handshake never arrives (a real bug,
// separately fixed at its root cause in AIUI's archyBridge.ts) — this
// proves the archy side never depends on that fix alone.
it('dismisses the loading overlay after a bounded timeout even if no ready message ever arrives', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(7999)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
// The connection indicator must NOT falsely report connected — the
// timeout only dismisses the blocking overlay, it does not fabricate
// a successful handshake.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
it('does not dismiss the loading overlay before the timeout elapses', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
await vi.advanceTimersByTimeAsync(4000)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
})
@@ -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('/')
})
})
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Chromium/Brave mis-rasterise `backdrop-filter` inside the dashboard's
* animated perspective/scroll containers. style.css already neutralises it
* for the shared glass classes, but that list is hand-maintained: a component
* that declares its own `backdrop-filter` in a local <style> block is simply
* not covered, and nothing fails.
*
* That is exactly how the 2026-08-03 seam shipped. `.home-card-shell` carried
* `backdrop-filter: blur(18px)` in Home.vue and was missing from the list, so
* a hover repaint left a vertical line where the refreshed backdrop met the
* stale one — visible in both dashboard cards at the same screen x, and
* absent in the gap between them.
*
* This test makes the omission fail loudly instead of shipping as a glitch
* nobody can reproduce on demand.
*/
const root = resolve(__dirname, '../../..')
const styleCss = readFileSync(resolve(root, 'src/style.css'), 'utf8')
/** The selector list that disables backdrop-filter on the dashboard. */
function dashboardMitigationBlock(): string {
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
expect(start, 'dashboard backdrop-filter mitigation block not found').toBeGreaterThan(-1)
const end = styleCss.indexOf('}', start)
return styleCss.slice(start, end)
}
/** Class selectors that declare a non-none backdrop-filter in a .vue file. */
function blurredClassesIn(relPath: string): string[] {
const src = readFileSync(resolve(root, relPath), 'utf8')
const found = new Set<string>()
// Match `.some-class { ... backdrop-filter: <not none> ... }` on one line,
// which is how these single-line rules are written in this codebase.
const ruleRe = /(\.[a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g
let m: RegExpExecArray | null
while ((m = ruleRe.exec(src)) !== null) {
const selector = m[1]
const body = m[2]
if (!selector || !body) continue
const decl = /(?:^|[;{\s])backdrop-filter\s*:\s*([^;]+)/.exec(body)
if (decl?.[1] && decl[1].trim() !== 'none') found.add(selector)
}
return [...found]
}
describe('dashboard backdrop-filter mitigation', () => {
it('covers every backdrop-filter surface Home.vue defines itself', () => {
const block = dashboardMitigationBlock()
const uncovered = blurredClassesIn('src/views/Home.vue').filter(
(sel) => !block.includes(`.dashboard-scroll-panel ${sel},`),
)
expect(
uncovered,
`these Home.vue classes declare backdrop-filter but are not in the ` +
`body.dashboard-active .dashboard-scroll-panel mitigation list in style.css, ` +
`so Chromium will leave repaint seams across the dashboard cards`,
).toEqual([])
})
it('still lists the shared glass classes', () => {
// Guards against someone "cleaning up" the list and silently reopening
// the original black-rectangle corruption this block was written for.
const block = dashboardMitigationBlock()
for (const sel of ['.glass-card', '.glass-button', '.home-card-shell']) {
expect(block).toContain(`.dashboard-scroll-panel ${sel},`)
}
})
it('the mitigation actually disables the filter', () => {
const start = styleCss.indexOf('body.dashboard-active .dashboard-scroll-panel .glass-card')
const body = styleCss.slice(styleCss.indexOf('{', start), styleCss.indexOf('}', start))
expect(body).toContain('backdrop-filter: none')
expect(body).toContain('-webkit-backdrop-filter: none')
})
})
@@ -0,0 +1,351 @@
// 02-06 Task 2: caches Home's system stats, update status and cloud storage
// usage behind keyed useCachedResource entries (every-entry, TTL-gated), and
// wraps the existing loadWeb5Status() wallet fetch in its own resource that
// revalidates UNCONDITIONALLY on every activation (T-02-13) — a money figure
// must never be presented as current without a visible re-check. Web5.vue's
// own two resources (web5.networking-profits, web5.lnd-info) were read and
// are NOT shared here — see 02-06-SUMMARY.md for the finding.
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Home from '../Home.vue'
import HomeWalletCard from '../home/HomeWalletCard.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/websocket', () => ({
wsClient: { subscribe: vi.fn(() => vi.fn()) },
}))
vi.mock('@/api/filebrowser-client', () => ({
fileBrowserClient: {
getUsage: vi.fn().mockResolvedValue({ totalSize: 1024, folderCount: 2, fileCount: 5 }),
},
}))
// Records every rpcClient.call({method}) invocation. wallet/system/update
// calls all route through call(); nothing Home.vue uses goes through a
// separate convenience method (unlike Server.vue's vpnStatus/dnsStatus).
async function defaultRpcCallImpl({ method }: { method: string }) {
switch (method) {
case 'system.stats':
return { cpu_usage_percent: 10, mem_used_bytes: 100, mem_total_bytes: 200, disk_used_bytes: 1, disk_total_bytes: 2, uptime_secs: 60 }
case 'bitcoin.getinfo':
return { block_height: 100, sync_progress: 1 }
case 'fips.status':
return { installed: false, service_active: false, key_present: false }
case 'openwrt.get-status':
return { tollgate: { installed: false } }
case 'update.status':
return { update_available: false }
case 'lnd.getinfo':
return { balance_sats: 5000, channel_balance_sats: 2500, synced_to_chain: true }
case 'wallet.ecash-balance':
return { balance_sats: 100 }
case 'wallet.fedimint-balance':
return { balance_sats: 0 }
case 'wallet.ark-balance':
return { spendable_sats: 0 }
case 'lnd.gettransactions':
return { transactions: [], incoming_pending_count: 0 }
case 'lnd.lightning-history':
return { transactions: [] }
case 'wallet.ecash-history':
return { transactions: [] }
default:
return {}
}
}
const rpcCallMock = vi.fn(defaultRpcCallImpl)
const vpnStatusMock = vi.fn(async () => ({
connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '',
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountHomeHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Home, { key: 'home' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
async function toggleTab(wrapper: ReturnType<typeof mountHomeHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
// loadWeb5Status()'s nested Promise.allSettled chains (balances + histories,
// each wrapping several rpcClient.call().then() hops) need more than one
// macrotask boundary to fully settle under fake timers — a single
// flushPromises() left the wallet resource's loadState at 'loading' in
// practice. Two calls reliably drain it.
async function settle() {
await flushPromises()
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
// Guard against a leaking permanent mockImplementation() override from a
// prior test (e.g. the never-resolving-promise case below) — mockClear()
// only clears call history, not a permanently swapped implementation.
rpcCallMock.mockImplementation(defaultRpcCallImpl)
vpnStatusMock.mockClear()
try {
sessionStorage.clear()
localStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Home tab cache (Task 2): system/update/storage groups + wallet freshness', () => {
it('reactivating inside the TTL issues zero new RPCs for the system, update and storage-usage groups', async () => {
const wrapper = mountHomeHost()
await settle()
const before = {
stats: callCountFor('system.stats'),
btc: callCountFor('bitcoin.getinfo'),
fips: callCountFor('fips.status'),
tollgate: callCountFor('openwrt.get-status'),
vpn: vpnStatusMock.mock.calls.length,
update: callCountFor('update.status'),
usage: vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length,
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await settle()
expect(callCountFor('system.stats')).toBe(before.stats)
expect(callCountFor('bitcoin.getinfo')).toBe(before.btc)
expect(callCountFor('fips.status')).toBe(before.fips)
expect(callCountFor('openwrt.get-status')).toBe(before.tollgate)
expect(vpnStatusMock.mock.calls.length).toBe(before.vpn)
expect(callCountFor('update.status')).toBe(before.update)
expect(vi.mocked((await import('@/api/filebrowser-client')).fileBrowserClient.getUsage).mock.calls.length).toBe(before.usage)
wrapper.unmount()
})
it('reactivating always triggers a wallet revalidation even when the TTL has not lapsed', async () => {
const wrapper = mountHomeHost()
await settle()
const lndCallsAtMount = callCountFor('lnd.getinfo')
expect(lndCallsAtMount).toBeGreaterThan(0)
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000) // nowhere near stale by any TTL definition
await toggleTab(wrapper, true)
await settle()
// Unconditional — fires again regardless of staleness (T-02-13).
expect(callCountFor('lnd.getinfo')).toBe(lndCallsAtMount + 1)
wrapper.unmount()
})
it('the previously rendered wallet figure stays in the DOM throughout a wallet revalidation', async () => {
const wrapper = mount(Home, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
await settle()
const walletCardBefore = wrapper.findComponent(HomeWalletCard)
expect(walletCardBefore.exists()).toBe(true)
expect(walletCardBefore.props('walletOnchain')).toBe(5000)
// Trigger a fresh revalidation directly (mirrors what reactivation does)
// and assert the card's props are still populated with the prior figure
// before the new fetch resolves — never blanked/reset to 0 mid-flight.
let resolveLnd!: (v: unknown) => void
rpcCallMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLnd = resolve as (v: unknown) => void }) as ReturnType<typeof defaultRpcCallImpl>)
const pending = (wrapper.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
await wrapper.vm.$nextTick()
const walletCardDuring = wrapper.findComponent(HomeWalletCard)
expect(walletCardDuring.props('walletOnchain')).toBe(5000)
resolveLnd({ balance_sats: 6000, channel_balance_sats: 2500, synced_to_chain: true })
await pending
await settle()
wrapper.unmount()
})
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
const wrapper = mountHomeHost()
await settle()
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000)
await toggleTab(wrapper, true)
await settle()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('home.wallet-status')).toBeNull()
// Non-sensitive groups may persist.
expect(readSnapshot('home.system-stats')).not.toBeNull()
expect(readSnapshot('home.update-status')).not.toBeNull()
expect(readSnapshot('home.cloud-usage')).not.toBeNull()
wrapper.unmount()
})
it('hydrateWalletSnapshot still paints last-known figures before any network round-trip', async () => {
localStorage.setItem('archy-wallet-snapshot-v1', JSON.stringify({
onchain: 42000, lightning: 1000, ecash: 0, fedimint: 0, ark: 0, connected: true, transactions: [],
}))
// Defer every RPC indefinitely so nothing resolves before the assertion.
rpcCallMock.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const wrapper = mount(Home, {
global: {
plugins: [createPinia()],
stubs: {
HomeSystemCard: true,
EasyHome: true,
WalletScanModal: true,
SendBitcoinModal: true,
ReceiveBitcoinModal: true,
TransactionsModal: true,
WalletSettingsModal: true,
},
},
})
await wrapper.vm.$nextTick()
const walletCard = wrapper.findComponent(HomeWalletCard)
expect(walletCard.props('walletOnchain')).toBe(42000)
expect(walletCard.props('walletLightning')).toBe(1000)
wrapper.unmount()
})
it('the websocket wallet-push path (02-04) is still present and still triggers a wallet refresh', async () => {
const { wsClient } = await import('@/api/websocket')
const wrapper = mountHomeHost()
await settle()
expect(vi.mocked(wsClient.subscribe)).toHaveBeenCalled()
const subscribeCalls = vi.mocked(wsClient.subscribe).mock.calls
const pushHandler = subscribeCalls[0]?.[0] as (() => void) | undefined
expect(pushHandler).toBeDefined()
const lndCallsBefore = callCountFor('lnd.getinfo')
pushHandler?.()
vi.advanceTimersByTime(800) // the debounce window
await settle()
expect(callCountFor('lnd.getinfo')).toBe(lndCallsBefore + 1)
wrapper.unmount()
})
it('renders RefreshIndicator bound to the wallet resource\'s loadState', async () => {
const wrapper = mountHomeHost()
await settle()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(1000)
await toggleTab(wrapper, true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await settle()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
it('every fetcher backing the wallet composite call passes dedup:true', async () => {
const wrapper = mountHomeHost()
await settle()
const walletMethods = [
'lnd.getinfo', 'wallet.ecash-balance', 'wallet.fedimint-balance', 'wallet.ark-balance',
'lnd.gettransactions', 'lnd.lightning-history', 'wallet.ecash-history',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => walletMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBeGreaterThanOrEqual(walletMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
login: vi.fn(),
call: vi.fn(),
isOnboardingComplete: vi.fn().mockResolvedValue(true),
},
}))
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(),
}))
vi.mock('@/composables/useLoginSounds', () => ({
ensureContext: vi.fn(),
playLoopStart: vi.fn(),
startSynthwave: vi.fn(),
stopSynthwave: vi.fn(),
playPop: vi.fn(),
playLoginSuccessWhoosh: vi.fn(),
playTypingSound: vi.fn(),
playDashboardLoadOomph: vi.fn(),
getContext: vi.fn(),
}))
vi.mock('@/composables/useOnboarding', () => ({
isOnboardingComplete: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/components/AnimatedLogo.vue', () => ({
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
}))
const pushMock = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ query: {} }),
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), onError: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
createWebHistory: vi.fn(),
}))
// Stub fetch for server health check
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: { message: 'ping' } }),
}))
import Login from '../Login.vue'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
login: {
title: 'Welcome Back',
setupTitle: 'Create Password',
password: 'Password',
confirmPassword: 'Confirm Password',
loginButton: 'Login',
setupButton: 'Create Password',
serverStarting: 'Starting server...',
errorMinLength: 'Password must be at least 8 characters',
errorMismatch: 'Passwords do not match',
errorIncorrect: 'Incorrect password',
errorNetwork: 'Unable to reach server',
},
},
},
})
describe('Login View', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
pushMock.mockResolvedValue(undefined)
// Mock health check so Login renders the form (not "Starting server...")
mockedRpc.call.mockImplementation(async (opts: any) => {
if (opts.method === 'server.echo') return { message: 'pong' }
if (opts.method === 'auth.isSetup') return { isSetup: true }
return null
})
})
function mountLogin() {
return shallowMount(Login, {
global: {
plugins: [createPinia(), i18n],
stubs: {
AnimatedLogo: defineComponent({ render: () => h('div') }),
Transition: true,
},
},
})
}
it('renders login page', () => {
const wrapper = mountLogin()
expect(wrapper.exists()).toBe(true)
})
it('contains a password input', async () => {
const wrapper = mountLogin()
await flushPromises()
const input = wrapper.find('input[type="password"]')
expect(input.exists()).toBe(true)
})
it('shows title text', async () => {
const wrapper = mountLogin()
await flushPromises()
expect(wrapper.text()).toContain('Welcome Back')
})
it('has a login button', async () => {
const wrapper = mountLogin()
await flushPromises()
const buttons = wrapper.findAll('button')
const loginBtn = buttons.find(b => b.text().includes('Login') || b.text().includes('Create'))
expect(loginBtn).toBeDefined()
})
it('shows error for empty password submission', async () => {
const wrapper = mountLogin()
// Find and submit the form
const form = wrapper.find('form')
if (form.exists()) {
await form.trigger('submit')
} else {
// Try clicking submit button
const btn = wrapper.findAll('button').find(b =>
b.text().includes('Login') || b.text().includes('Create')
)
if (btn) await btn.trigger('click')
}
// No assertion on specific error text — login requires password
})
it('calls rpcClient.login on form submission with password', async () => {
mockedRpc.login.mockResolvedValue(null)
const wrapper = mountLogin()
// Set password
const input = wrapper.find('input[type="password"]')
if (input.exists()) {
await input.setValue('testpassword123')
}
// Submit
const form = wrapper.find('form')
if (form.exists()) {
await form.trigger('submit')
}
})
})
@@ -0,0 +1,353 @@
// 02-05: caches the Mesh tab's six uncached fetch groups behind
// useCachedResource entries, and bounds the Leaflet map's lifecycle across
// Mesh.vue's activate/deactivate cycle (established in 02-04).
//
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Mesh from '../Mesh.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useMeshStore } from '@/stores/mesh'
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
}))
vi.mock('@/api/websocket', () => ({
wsClient: { subscribe: vi.fn(() => vi.fn()) },
}))
function meshStatusPayload(overrides: Record<string, unknown> = {}) {
return {
enabled: true,
device_type: 'meshcore',
device_path: null,
device_connected: true,
firmware_version: null,
self_node_id: 1,
self_advert_name: 'Self',
peer_count: 0,
channel_name: 'Public',
messages_sent: 0,
messages_received: 0,
...overrides,
}
}
// Records every rpcClient.call({method}) invocation so tests can assert per-
// group call counts and start-order without depending on real network I/O.
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
switch (method) {
case 'mesh.status':
return meshStatusPayload()
case 'mesh.peers':
return { peers: [], count: 0 }
case 'mesh.messages':
return { messages: [], count: 0 }
case 'mesh.deadman-status':
return { enabled: false }
case 'mesh.block-headers':
return { headers: [], latest_height: 0, count: 0 }
case 'transport.status':
return { transports: [], mesh_only: false, peer_count: 0 }
default:
return {}
}
})
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:z6Mkself' }),
meshContactsList: vi.fn().mockResolvedValue({ contacts: [] }),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountMeshHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
AnimatedLogo: true,
MeshMap: true,
MeshBitcoinPanel: true,
MeshDeadmanPanel: true,
MeshDevicePanel: true,
MeshAssistantPanel: true,
HopVizModal: true,
MediaLightbox: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
function convenienceCallCounts() {
return {
federationListNodes: vi.mocked(rpcClient.federationListNodes).mock.calls.length,
getTorAddress: vi.mocked(rpcClient.getTorAddress).mock.calls.length,
getNodeDid: vi.mocked(rpcClient.getNodeDid).mock.calls.length,
meshContactsList: vi.mocked(rpcClient.meshContactsList).mock.calls.length,
}
}
async function toggleTab(wrapper: ReturnType<typeof mountMeshHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
vi.mocked(rpcClient.federationListNodes).mockClear()
vi.mocked(rpcClient.getTorAddress).mockClear()
vi.mocked(rpcClient.getNodeDid).mockClear()
vi.mocked(rpcClient.meshContactsList).mockClear()
try {
sessionStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Mesh tab cache (Task 1): six fetch groups', () => {
it('a cold load fires all six groups concurrently — every RPC has already started before any microtask resolves', async () => {
const wrapper = mountMeshHost()
// Synchronously (no await, no flushPromises yet) every one of the six
// groups' underlying calls must already have fired: armMeshLive() runs
// Promise.allSettled(meshCachedGroups.map(refreshMeshGroupIfStale)),
// and none of the intermediate layers (refreshMeshGroupIfStale ->
// store.refresh -> fetcher -> mesh.refreshAll/refreshFederationNodes/etc
// -> rpcClient.call) awaits anything before reaching the RPC call — a
// serialized chain (awaiting one group before starting the next) could
// not possibly have reached all six yet at this point.
expect(callCountFor('mesh.status')).toBe(1)
expect(callCountFor('mesh.peers')).toBe(1)
expect(callCountFor('mesh.messages')).toBe(1)
expect(callCountFor('mesh.deadman-status')).toBe(1)
expect(callCountFor('mesh.block-headers')).toBe(1)
expect(callCountFor('transport.status')).toBe(1)
expect(convenienceCallCounts()).toEqual({
federationListNodes: 1, getTorAddress: 1, getNodeDid: 1, meshContactsList: 1,
})
await flushPromises()
wrapper.unmount()
})
it('reactivating inside every group\'s TTL issues zero additional RPCs across all six groups', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const before = {
status: callCountFor('mesh.status'),
peers: callCountFor('mesh.peers'),
messages: callCountFor('mesh.messages'),
deadman: callCountFor('mesh.deadman-status'),
headers: callCountFor('mesh.block-headers'),
transport: callCountFor('transport.status'),
...convenienceCallCounts(),
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('mesh.status')).toBe(before.status)
expect(callCountFor('mesh.peers')).toBe(before.peers)
expect(callCountFor('mesh.messages')).toBe(before.messages)
expect(callCountFor('mesh.deadman-status')).toBe(before.deadman)
expect(callCountFor('mesh.block-headers')).toBe(before.headers)
expect(callCountFor('transport.status')).toBe(before.transport)
expect(convenienceCallCounts()).toEqual({
federationListNodes: before.federationListNodes,
getTorAddress: before.getTorAddress,
getNodeDid: before.getNodeDid,
meshContactsList: before.meshContactsList,
})
wrapper.unmount()
})
it('reactivating past the short (10s) TTL revalidates the fast-moving groups while the near-static identity groups stay cached', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const statusCallsAtMount = callCountFor('mesh.status')
const transportCallsAtMount = callCountFor('transport.status')
const onionCallsAtMount = vi.mocked(rpcClient.getTorAddress).mock.calls.length
const didCallsAtMount = vi.mocked(rpcClient.getNodeDid).mock.calls.length
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000) // past the 10s reachability/transport TTL, well under the 300s identity TTL
await toggleTab(wrapper, true)
await flushPromises()
// Peer/status/transport (reachability-class, 10s TTL) revalidate exactly once.
expect(callCountFor('mesh.status')).toBe(statusCallsAtMount + 1)
expect(callCountFor('transport.status')).toBe(transportCallsAtMount + 1)
// This node's own onion/DID (300s TTL, near-static identity) are still fresh.
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBe(onionCallsAtMount)
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBe(didCallsAtMount)
wrapper.unmount()
})
it('the previous graph/peer data stays rendered while a stale group revalidates (sticky-ready, no blank frame)', async () => {
const wrapper = mountMeshHost()
await flushPromises()
expect(wrapper.text()).toContain('Mesh Network')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000)
await toggleTab(wrapper, true)
// Before the revalidation resolves, prior content must still be present
// (sticky-ready never regresses to a blank/loading state).
expect(wrapper.text()).toContain('Mesh Network')
await flushPromises()
expect(wrapper.text()).toContain('Mesh Network')
wrapper.unmount()
})
it('one group rejecting leaves the other five unaffected and the fan-out still completes (deep-link/outbox callback runs)', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const meshStore = useMeshStore()
const rejectingRefreshAll = vi.fn().mockRejectedValue(new Error('boom'))
// Bypass the store's own internal try/catch entirely so a genuine
// rejection reaches useCachedResource's fetcher wrapper.
meshStore.refreshAll = rejectingRefreshAll
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Mesh, { key: 'mesh' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, {
global: {
plugins: [pinia],
stubs: {
AnimatedLogo: true, MeshMap: true, MeshBitcoinPanel: true, MeshDeadmanPanel: true,
MeshDevicePanel: true, MeshAssistantPanel: true, HopVizModal: true, MediaLightbox: true,
},
},
})
await flushPromises()
expect(rejectingRefreshAll).toHaveBeenCalled()
// The other five groups still ran to completion.
expect(callCountFor('transport.status')).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.federationListNodes).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.getTorAddress).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.getNodeDid).mock.calls.length).toBeGreaterThan(0)
expect(vi.mocked(rpcClient.meshContactsList).mock.calls.length).toBeGreaterThan(0)
// The .then() callback after the fan-out (refreshOutboxCount -> mesh.outbox) still ran.
expect(callCountFor('mesh.outbox')).toBeGreaterThan(0)
wrapper.unmount()
})
it('every group carrying peer or self identity data declares persist:false; only the non-identity transport status may persist', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('mesh.refresh-all')).toBeNull() // carries mesh.peers (DIDs/pubkeys)
expect(readSnapshot('mesh.federation-nodes')).toBeNull() // DID/pubkey/onion
expect(readSnapshot('mesh.self-onion')).toBeNull() // this node's own onion
expect(readSnapshot('mesh.self-did')).toBeNull() // this node's own DID
expect(readSnapshot('mesh.contacts')).toBeNull() // contact records/aliases
expect(readSnapshot('mesh.transport-status')).not.toBeNull() // non-identity aggregate
wrapper.unmount()
})
it('every fetcher backing the six cache groups is registered with dedup:true', async () => {
const wrapper = mountMeshHost()
await flushPromises()
// Restrict to the six cache groups' own methods — other rpcClient.call
// traffic incidental to the fan-out (e.g. mesh.outbox, refreshed from the
// post-fan-out .then()) isn't one of the cached groups and is out of
// scope for this assertion.
const cachedMethods = [
'mesh.status', 'mesh.peers', 'mesh.messages', 'mesh.deadman-status',
'mesh.block-headers', 'transport.status',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBe(cachedMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
it('renders RefreshIndicator wired to whether any of the six groups is refreshing', async () => {
const wrapper = mountMeshHost()
await flushPromises()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
// Idle once everything has settled.
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(15000)
await toggleTab(wrapper, true)
// Immediately after reactivation (before the revalidation resolves) the
// indicator must be visible — peer reachability must never present a
// frozen state as current without a visible refresh signal (T-02-13).
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await flushPromises()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
})
@@ -0,0 +1,408 @@
// Per-item keyed useCachedResource conversions for secondary screens (D-04,
// plan 02-03). Pins: instant repeat-open from cache, no new RPC inside the
// TTL, per-item key isolation (rendered content, not just call counts),
// TTL-lapse revalidation, and keep-last-value on a rejected background
// refresh (D-07).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, type Pinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDetails from '../AppDetails.vue'
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
import OpenWrtGateway from '../server/OpenWrtGateway.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
getPackageVersions: vi.fn(),
},
}))
let currentRouteParams: Record<string, string> = {}
let currentRouteQuery: Record<string, string> = {}
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }),
useRoute: () => ({ params: currentRouteParams, query: currentRouteQuery }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
let currentPackages: Record<string, unknown> = {}
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
packages: currentPackages,
startPackage: vi.fn().mockResolvedValue(undefined),
stopPackage: vi.fn().mockResolvedValue(undefined),
restartPackage: vi.fn().mockResolvedValue(undefined),
updatePackage: vi.fn().mockResolvedValue(undefined),
uninstallPackage: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('@/composables/useMarketplaceApp', () => ({
useMarketplaceApp: () => ({ getCurrentApp: () => currentMarketplaceApp }),
}))
let currentMarketplaceApp: Record<string, unknown> | null = null
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 makePkg(overrides: Record<string, unknown> = {}) {
return {
manifest: { id: 'pkg', version: '1.0.0', title: 'Test App' },
state: 'running',
health: 'healthy',
installed: null,
...overrides,
}
}
// A single Pinia instance is shared across the unmount/remount pairs within
// one test — matching production, where the resources store's in-memory
// entries Map is a singleton that survives navigating away from and back to
// a secondary screen (only a full page reload, or clearAll() on logout,
// resets it). Creating a fresh Pinia per mount would wipe persist:false
// entries (credentials) between "visits" and falsely fail the cache-hit
// assertions below.
function mountAppDetails(id: string, packages: Record<string, unknown>, pinia: Pinia) {
currentRouteParams = { id }
currentRouteQuery = {}
currentPackages = packages
return mount(AppDetails, {
global: {
plugins: [pinia],
stubs: { AppHeroSection: true, AppContentSection: true, LndSeedBackup: true, AppsUninstallModal: true },
mocks: { $ver: (v: string) => v },
},
})
}
describe('AppDetails.vue — per-item cached resources (bitcoin sync + credentials)', () => {
beforeEach(() => {
sessionStorage.clear()
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
id: 'x', supportsVersions: false, default: null, installedVersion: null,
pinnedVersion: null, autoUpdate: false, versions: [],
})
})
afterEach(() => {
vi.useRealTimers()
})
function credentialsHandler(byId: Record<string, { label: string; value: string }>) {
return vi.fn((req: { method: string; params?: { app_id?: string } }) => {
if (req.method === 'package.credentials') {
const appId = req.params?.app_id ?? ''
const cred = byId[appId]
return Promise.resolve(cred ? { credentials: [cred] } : { credentials: [] })
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
}
it('repeat mount for the same app id inside the TTL issues exactly one credentials fetch total', async () => {
const pinia = createPinia()
const handler = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
vi.mocked(rpcClient.call).mockImplementation(handler as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w1.unmount()
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w2.unmount()
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
expect(credCalls.length).toBe(1)
})
it('mounting for app id alpha then beta issues two credentials fetches and never renders alpha data for beta', async () => {
const pinia = createPinia()
const handler = credentialsHandler({
'app-alpha': { label: 'Password', value: 'alpha-secret' },
'app-beta': { label: 'Password', value: 'beta-secret' },
})
vi.mocked(rpcClient.call).mockImplementation(handler as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
expect(w1.text()).toContain('alpha-secret')
w1.unmount()
const w2 = mountAppDetails('app-beta', { 'app-beta': makePkg() }, pinia)
await flushPromises()
expect(w2.text()).toContain('beta-secret')
expect(w2.text()).not.toContain('alpha-secret')
w2.unmount()
const credCalls = handler.mock.calls.filter((c) => (c[0] as { method: string }).method === 'package.credentials')
expect(credCalls.length).toBe(2)
})
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
let credentialCallCount = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') {
credentialCallCount++
return Promise.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
w1.unmount()
vi.advanceTimersByTime(31_000) // past the 30s TTL
// A deferred second fetch lets us observe the cached value on-screen
// before the TTL-triggered revalidate resolves.
const stalled = deferred<{ credentials: { label: string; value: string }[] }>()
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') {
credentialCallCount++
return stalled.promise
}
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await w2.vm.$nextTick()
// Cached value renders before the (TTL-triggered) revalidate resolves.
expect(w2.text()).toContain('alpha-secret')
stalled.resolve({ credentials: [{ label: 'Password', value: 'alpha-secret' }] })
await flushPromises()
w2.unmount()
expect(credentialCallCount).toBe(2)
})
it('a rejected background refresh keeps the previously rendered credentials on screen', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
const good = credentialsHandler({ 'app-alpha': { label: 'Password', value: 'alpha-secret' } })
vi.mocked(rpcClient.call).mockImplementation(good as never)
const w1 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
expect(w1.text()).toContain('alpha-secret')
w1.unmount()
vi.advanceTimersByTime(31_000)
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'package.credentials') return Promise.reject(new Error('offline'))
if (req.method === 'bitcoin.getinfo') return Promise.resolve({ block_height: 0, sync_progress: 0 })
return Promise.resolve({})
})
const w2 = mountAppDetails('app-alpha', { 'app-alpha': makePkg() }, pinia)
await flushPromises()
// Keep-last-value (D-07): the in-memory cache still shows the previous
// credential even though the revalidate failed.
expect(w2.text()).toContain('alpha-secret')
w2.unmount()
})
it('bitcoin-sync and credentials fetchers for a single mount are issued concurrently, not one after another', async () => {
const pinia = createPinia()
const bitcoinDeferred = deferred<{ block_height: number; sync_progress: number }>()
const credsDeferred = deferred<{ credentials: { label: string; value: string }[] }>()
const calledMethods: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
calledMethods.push(req.method)
if (req.method === 'bitcoin.getinfo') return bitcoinDeferred.promise
if (req.method === 'package.credentials') return credsDeferred.promise
return Promise.resolve({})
})
// 'mempool-electrs' is in BITCOIN_DEPENDENT_APPS and maps to itself via
// ROUTE_TO_PACKAGE_KEY, so this id exercises the bitcoin-sync resource.
const w = mountAppDetails('mempool-electrs', { 'mempool-electrs': makePkg() }, pinia)
await w.vm.$nextTick()
// Both fetchers must already be in flight before either resolves —
// proof neither loader awaited the other.
expect(calledMethods).toContain('bitcoin.getinfo')
expect(calledMethods).toContain('package.credentials')
bitcoinDeferred.resolve({ block_height: 800000, sync_progress: 1 })
credsDeferred.resolve({ credentials: [] })
await flushPromises()
w.unmount()
})
})
describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () => {
beforeEach(() => {
sessionStorage.clear()
currentRouteQuery = {}
})
afterEach(() => {
vi.useRealTimers()
})
function mountMarketplaceDetails(id: string, app: Record<string, unknown> | null) {
currentRouteParams = { id }
currentRouteQuery = {}
currentMarketplaceApp = app
return mount(MarketplaceAppDetails, {
global: {
plugins: [createPinia()],
mocks: { $ver: (v: string) => v },
},
})
}
function makeApp(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
title: `App ${id}`,
description: 'desc',
version: '1.0.0',
dockerImage: `registry/${id}:latest`,
screenshots: [],
...overrides,
}
}
it('repeat mount for the same marketplace app id inside the TTL issues exactly one package.versions fetch', async () => {
const calls: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
calls.push(req.method)
if (req.method === 'package.versions') {
return Promise.resolve({ id: 'demo-app', supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
}
return Promise.resolve({})
})
const w1 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
await flushPromises()
w1.unmount()
const w2 = mountMarketplaceDetails('demo-app', makeApp('demo-app'))
await flushPromises()
w2.unmount()
expect(calls.filter((m) => m === 'package.versions').length).toBe(1)
})
it('mounting for marketplace app id alpha then beta each issue their own versions fetch (distinct per-item keys)', async () => {
const requestedIds: string[] = []
vi.mocked(rpcClient.call).mockImplementation((req: { method: string; params?: { id?: string } }) => {
if (req.method === 'package.versions') {
requestedIds.push(req.params?.id ?? '')
return Promise.resolve({ id: req.params?.id, supportsVersions: false, default: null, installedVersion: null, pinnedVersion: null, autoUpdate: false, versions: [] })
}
return Promise.resolve({})
})
const w1 = mountMarketplaceDetails('mkt-alpha', makeApp('mkt-alpha'))
await flushPromises()
w1.unmount()
const w2 = mountMarketplaceDetails('mkt-beta', makeApp('mkt-beta'))
await flushPromises()
w2.unmount()
expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta'])
})
})
describe('OpenWrtGateway.vue — cached router status (no item id: one gateway per node)', () => {
beforeEach(() => {
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
function makeStatus(hostname: string) {
return {
host: '192.168.1.1',
hostname,
uptime_secs: 100,
release: {},
tollgate: { installed: false },
wifi_interfaces: [],
wan: { configured: false, ssid: '', assoc_ssid: '', encryption: '', ip: '', internet: false, radio0_disabled: false, sta_iface: '', sta_state: '' },
}
}
function mountGateway(pinia: Pinia) {
return mount(OpenWrtGateway, {
global: { plugins: [pinia] },
})
}
it('repeat mount inside the TTL issues exactly one openwrt.get-status fetch total', async () => {
const pinia = createPinia()
let calls = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'openwrt.get-status') {
calls++
return Promise.resolve(makeStatus('router-1'))
}
return Promise.resolve({})
})
const w1 = mountGateway(pinia)
await flushPromises()
expect(w1.text()).toContain('router-1')
w1.unmount()
const w2 = mountGateway(pinia)
await flushPromises()
expect(w2.text()).toContain('router-1')
w2.unmount()
expect(calls).toBe(1)
})
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
let calls = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'openwrt.get-status') {
calls++
return Promise.resolve(makeStatus('router-1'))
}
return Promise.resolve({})
})
const w1 = mountGateway(pinia)
await flushPromises()
w1.unmount()
vi.advanceTimersByTime(31_000)
const w2 = mountGateway(pinia)
await w2.vm.$nextTick()
expect(w2.text()).toContain('router-1')
await flushPromises()
w2.unmount()
expect(calls).toBe(2)
})
})
@@ -0,0 +1,298 @@
// 02-06 Task 1: caches the Server tab's seven load groups (network summary,
// FIPS summary, VPN peers, interfaces, Tor services — shared by
// checkTorStatus and loadTorServices — and disk status) behind keyed
// useCachedResource entries, with per-group TTL/persist decisions and the
// cold-load fan-out kept concurrent (RESEARCH A3 settled: none of the seven
// loaders consumes another's result — see 02-06-SUMMARY.md).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Server from '../Server.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
// Records every rpcClient.call({method}) invocation so tests can assert
// per-group call counts and concurrency without depending on real network
// I/O. vpnStatus/dnsStatus/diskStatus are separate convenience methods on
// rpcClient (not routed through call()), so they're mocked independently.
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
switch (method) {
case 'network.diagnostics':
return { tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' }
case 'router.list-forwards':
return { forwards: [] }
case 'network.list-interfaces':
return { interfaces: [] }
case 'tor.list-services':
return { services: [], tor_running: false }
case 'vpn.list-peers':
return { peers: [] }
case 'fips.status':
return { installed: false, service_active: false, key_present: false }
default:
return {}
}
})
const vpnStatusMock = vi.fn(async () => ({
connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24',
peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: true, configured_provider: 'wireguard',
}))
const dnsStatusMock = vi.fn(async () => ({
provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [],
}))
const diskStatusMock = vi.fn(async () => ({
used_bytes: 0, total_bytes: 0, free_bytes: 0, used_percent: 0, level: 'ok' as const,
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
dnsStatus: (...args: unknown[]) => (dnsStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
diskStatus: (...args: unknown[]) => (diskStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountServerHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Server, { key: 'server' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
async function toggleTab(wrapper: ReturnType<typeof mountServerHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
vpnStatusMock.mockClear()
dnsStatusMock.mockClear()
diskStatusMock.mockClear()
try {
sessionStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Server tab cache (Task 1): seven load groups', () => {
it('a cold load fires all seven groups concurrently — every RPC has already started before any microtask resolves', async () => {
const wrapper = mountServerHost()
// Synchronous check (no await yet): armServerEntryEffects/onMounted call
// all seven loaders without awaiting one before starting the next — a
// serialized chain could not have reached all seven RPCs yet here.
expect(callCountFor('network.diagnostics')).toBe(1)
expect(callCountFor('router.list-forwards')).toBe(1)
// vpnStatus fires twice on a fresh mount: once for network-summary's own
// fetch, once for the VPN poll's immediate first tick (armVpnPoll, a
// deliberate every-activation effect independent of this TTL cache).
expect(vpnStatusMock).toHaveBeenCalledTimes(2)
expect(dnsStatusMock).toHaveBeenCalledTimes(1)
expect(callCountFor('network.list-interfaces')).toBe(1)
expect(callCountFor('tor.list-services')).toBe(1)
expect(callCountFor('vpn.list-peers')).toBe(1)
expect(callCountFor('fips.status')).toBe(1)
expect(diskStatusMock).toHaveBeenCalledTimes(1)
await flushPromises()
wrapper.unmount()
})
it('reactivating inside every group\'s TTL issues zero additional RPCs across all seven groups', async () => {
const wrapper = mountServerHost()
await flushPromises()
const before = {
diag: callCountFor('network.diagnostics'),
fwd: callCountFor('router.list-forwards'),
iface: callCountFor('network.list-interfaces'),
tor: callCountFor('tor.list-services'),
vpnPeers: callCountFor('vpn.list-peers'),
fips: callCountFor('fips.status'),
vpnStatus: vpnStatusMock.mock.calls.length,
dns: dnsStatusMock.mock.calls.length,
disk: diskStatusMock.mock.calls.length,
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('network.diagnostics')).toBe(before.diag)
expect(callCountFor('router.list-forwards')).toBe(before.fwd)
expect(callCountFor('network.list-interfaces')).toBe(before.iface)
expect(callCountFor('tor.list-services')).toBe(before.tor)
expect(callCountFor('vpn.list-peers')).toBe(before.vpnPeers)
expect(callCountFor('fips.status')).toBe(before.fips)
// dnsStatus is purely network-summary's own TTL-gated call — untouched.
expect(dnsStatusMock.mock.calls.length).toBe(before.dns)
// vpnStatus is the one exception: armVpnPoll's immediate first tick on
// reactivation fires regardless of network-summary's own TTL (a
// deliberate every-activation VPN-IP freshness effect from 02-04, not
// something this task's TTL/persist conversion changes).
expect(vpnStatusMock.mock.calls.length).toBe(before.vpnStatus + 1)
expect(diskStatusMock.mock.calls.length).toBe(before.disk)
wrapper.unmount()
})
it('reactivating past the short (10s) TTL revalidates the fast groups while the longer-TTL FIPS/Tor/VPN-peer groups stay cached', async () => {
const wrapper = mountServerHost()
await flushPromises()
const diagAtMount = callCountFor('network.diagnostics')
const ifaceAtMount = callCountFor('network.list-interfaces')
const diskAtMount = diskStatusMock.mock.calls.length
const fipsAtMount = callCountFor('fips.status')
const torAtMount = callCountFor('tor.list-services')
const vpnPeersAtMount = callCountFor('vpn.list-peers')
await toggleTab(wrapper, false)
// Past the 10s fast tier, well under the 30s Tor/VPN-peer tier and the
// 60s FIPS tier.
vi.advanceTimersByTime(12000)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('network.diagnostics')).toBe(diagAtMount + 1)
expect(callCountFor('network.list-interfaces')).toBe(ifaceAtMount + 1)
expect(diskStatusMock.mock.calls.length).toBe(diskAtMount + 1)
// FIPS (60s), Tor services (30s) and VPN peers (30s) are still fresh.
expect(callCountFor('fips.status')).toBe(fipsAtMount)
expect(callCountFor('tor.list-services')).toBe(torAtMount)
expect(callCountFor('vpn.list-peers')).toBe(vpnPeersAtMount)
wrapper.unmount()
})
it('a rejected refresh in one group leaves the other six unaffected and keeps that group\'s last known data rendered', async () => {
// Mounted directly (no KeepAlive host) so the exposed loadNetworkData()
// method is reachable, matching ServerNetworkRefresh.test.ts's convention.
const wrapper = mount(Server, {
global: {
plugins: [createPinia()],
stubs: { QuickActionsCard: true, TorServicesCard: true, ServerModals: true, FipsNetworkCard: true },
},
})
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
rpcCallMock.mockImplementationOnce(async ({ method }: { method: string }) => {
if (method === 'network.diagnostics') throw new Error('offline')
return {}
})
await (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
await flushPromises()
// Prior network data stays rendered (keep-last-value on error, D-07).
expect(wrapper.text()).toContain('Lab WiFi')
// Other groups' own data is untouched by the rejection.
expect(diskStatusMock).toHaveBeenCalled()
expect(callCountFor('fips.status')).toBeGreaterThan(0)
expect(callCountFor('vpn.list-peers')).toBeGreaterThan(0)
expect(callCountFor('network.list-interfaces')).toBeGreaterThan(0)
expect(callCountFor('tor.list-services')).toBeGreaterThan(0)
wrapper.unmount()
})
it('groups carrying VPN peer identity, Tor onion addresses, or this node\'s own FIPS identity key declare persist:false; non-identity groups may persist', async () => {
const wrapper = mountServerHost()
await flushPromises()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity)
expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address
expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status
// CR-01 follow-up correction: `server.fips-summary` is shared with
// FipsNetworkCard.vue, whose fuller FipsStatus type shows the real
// `fips.status` response also carries `npub` — this node's own FIPS
// identity public key — so this key must be persist:false too (T-02-01),
// not persist:true as originally assumed when only the narrower
// installed/service_active/key_present fields were considered.
expect(readSnapshot('server.fips-summary')).toBeNull()
expect(readSnapshot('server.interfaces')).not.toBeNull()
expect(readSnapshot('server.disk-status')).not.toBeNull()
wrapper.unmount()
})
it('every fetcher backing the seven load groups is registered with dedup:true', async () => {
const wrapper = mountServerHost()
await flushPromises()
const cachedMethods = [
'network.diagnostics', 'router.list-forwards', 'network.list-interfaces',
'tor.list-services', 'vpn.list-peers', 'fips.status',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBe(cachedMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
it('renders RefreshIndicator wired to whether any of the seven groups is refreshing', async () => {
const wrapper = mountServerHost()
await flushPromises()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(12000)
await toggleTab(wrapper, true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await flushPromises()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
})
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Settings from '../Settings.vue'
describe('Settings View', () => {
it('renders AccountSection and SystemSection', () => {
setActivePinia(createPinia())
const wrapper = shallowMount(Settings)
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
})
})