Archipelago — open-source initial import
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user