Files
archy/packages/app/src/__tests__/archyIntegration.test.ts
T
DorianandClaude Opus 4.6 decee9163b test(archy): add integration test suite for archyBridge, useArchy, and archy-apps
20 tests covering postMessage protocol, composable API shape,
buildArchyContext format, archy-apps data integrity, and base-aware paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:22:38 +00:00

286 lines
11 KiB
TypeScript

/**
* Archy Integration Tests
*
* Tests archyBridge message handling, useArchy composable,
* and ArchyAppsGrid component behavior.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ═══════════════════════════════════════════════════════════════════
// archyBridge — postMessage handling
// ═══════════════════════════════════════════════════════════════════
describe('archyBridge: postMessage protocol', () => {
let originalParent: Window
let messageHandler: ((event: MessageEvent) => void) | null = null
beforeEach(() => {
originalParent = window.parent
// Mock window.parent to simulate being in an iframe
Object.defineProperty(window, 'parent', {
value: {
postMessage: vi.fn(),
},
writable: true,
configurable: true,
})
// Capture addEventListener calls to grab the message handler
const originalAddEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: any) => {
if (type === 'message') {
messageHandler = handler
}
return originalAddEventListener(type, handler)
})
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
vi.restoreAllMocks()
messageHandler = null
})
it('isInArchy returns true when window.parent !== window', async () => {
// Dynamic import to get fresh module state
const { archyBridge } = await import('@/services/archyBridge')
expect(archyBridge.isInArchy()).toBe(true)
})
it('init sends ready message to parent', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'ready' },
'*',
)
archyBridge.destroy()
})
it('requestContext sends context:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget — just verify the message shape
archyBridge.requestContext('apps').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'context:request',
category: 'apps',
}),
'*',
)
archyBridge.destroy()
})
it('requestAction sends action:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget
archyBridge.requestAction('open-app', { appId: 'mempool' }).catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'action:request',
action: 'open-app',
params: { appId: 'mempool' },
}),
'*',
)
archyBridge.destroy()
})
it('requestTheme sends theme:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
archyBridge.requestTheme()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'theme:request' },
'*',
)
archyBridge.destroy()
})
it('onPermissionsUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onPermissionsUpdate(callback)
// Unsubscribe should be a function
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('onThemeUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onThemeUpdate(callback)
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('getPermissions returns empty array initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const perms = archyBridge.getPermissions()
expect(Array.isArray(perms)).toBe(true)
})
it('getTheme returns null initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const theme = archyBridge.getTheme()
// May be null or have been set by a previous test
expect(theme === null || typeof theme === 'object').toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy — composable behavior
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: composable', () => {
it('exports expected API shape', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(archy).toHaveProperty('isEmbedded')
expect(archy).toHaveProperty('isInitialized')
expect(archy).toHaveProperty('permissions')
expect(archy).toHaveProperty('accentColor')
expect(archy).toHaveProperty('installedApps')
expect(archy).toHaveProperty('systemInfo')
expect(archy).toHaveProperty('networkInfo')
expect(archy).toHaveProperty('walletInfo')
expect(archy).toHaveProperty('fileList')
expect(archy).toHaveProperty('init')
expect(archy).toHaveProperty('destroy')
expect(archy).toHaveProperty('refreshContext')
expect(archy).toHaveProperty('requestAction')
expect(archy).toHaveProperty('buildArchyContext')
})
it('buildArchyContext returns empty string when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Not initialized, should return empty
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
it('requestAction returns failure when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
const result = await archy.requestAction('open-app', { appId: 'test' })
expect(result).toEqual({ success: false, error: 'Not initialized' })
})
it('isEmbedded and isInitialized are readonly refs', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Should be refs (have .value)
expect(typeof archy.isEmbedded.value).toBe('boolean')
expect(typeof archy.isInitialized.value).toBe('boolean')
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy: buildArchyContext output format
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: buildArchyContext format', () => {
it('context string includes wallet info when available', async () => {
// We can't easily set internal state without init, so we test the format expectations
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Context should be a string
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
})
// ═══════════════════════════════════════════════════════════════════
// archy-apps.ts — data file
// ═══════════════════════════════════════════════════════════════════
describe('archy-apps: data integrity', () => {
it('exports ARCHY_APPS array with all major services', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
expect(Array.isArray(ARCHY_APPS)).toBe(true)
expect(ARCHY_APPS.length).toBeGreaterThanOrEqual(15)
// Verify all required services are present
const ids = ARCHY_APPS.map((a) => a.id)
expect(ids).toContain('bitcoin-core')
expect(ids).toContain('lnd')
expect(ids).toContain('btcpay-server')
expect(ids).toContain('mempool')
expect(ids).toContain('nextcloud')
expect(ids).toContain('immich')
expect(ids).toContain('nostr-rs-relay')
expect(ids).toContain('home-assistant')
expect(ids).toContain('grafana')
expect(ids).toContain('searxng')
expect(ids).toContain('ollama')
expect(ids).toContain('penpot')
expect(ids).toContain('onlyoffice')
expect(ids).toContain('fedimint')
expect(ids).toContain('meshtastic')
})
it('each app has required fields', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.id).toBeTruthy()
expect(app.name).toBeTruthy()
expect(app.description).toBeTruthy()
expect(app.icon).toBeTruthy()
expect(app.category).toBeTruthy()
expect(app.deepLink).toBeTruthy()
expect(app.deepLink.startsWith('/app/')).toBe(true)
}
})
it('getArchyApp looks up apps by ID', async () => {
const { getArchyApp } = await import('@/data/archy-apps')
const btc = getArchyApp('bitcoin-core')
expect(btc).toBeDefined()
expect(btc!.name).toBe('Bitcoin Core')
const missing = getArchyApp('nonexistent-app')
expect(missing).toBeUndefined()
})
it('app IDs are unique', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
const ids = ARCHY_APPS.map((a) => a.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('deep links follow /app/{id} pattern', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.deepLink).toBe(`/app/${app.id}`)
}
})
})
// ═══════════════════════════════════════════════════════════════════
// Base-aware API paths
// ═══════════════════════════════════════════════════════════════════
describe('Base-aware API paths', () => {
it('import.meta.env.BASE_URL is defined', () => {
// In Vite test environment, BASE_URL defaults to '/'
expect(typeof import.meta.env.BASE_URL).toBe('string')
expect(import.meta.env.BASE_URL).toBeTruthy()
})
})