Files
sky_phone-sky-systems/frontend/src/utils/customAppBridge.test.ts
T
2026-08-11 19:23:40 +02:00

279 lines
8.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createCustomAppBridgeRequestHandler,
getCustomAppFrameBootstrapMessages,
getSkyPhoneAppCapabilities,
shouldReportCustomAppReady,
type CustomAppBridgeNotification,
type CustomAppBridgeTarget,
} from '@/utils/customAppBridge'
const source = {
capabilities: [] as string[],
id: 'source-app',
}
const createNotification =
vi.fn<(input: CustomAppBridgeNotification) => string | null>()
const prepareExternalOpen = vi.fn<(appId: string, data?: unknown) => boolean>()
const resolveTarget = vi.fn<(appId: string) => CustomAppBridgeTarget | null>()
const storageCall = vi.fn()
function createHandler() {
return createCustomAppBridgeRequestHandler({
createNotification,
getSourceApp: () => source,
prepareExternalOpen,
resolveTarget,
storageCall,
})
}
describe('custom app bridge', () => {
beforeEach(() => {
source.capabilities = []
createNotification.mockReset().mockReturnValue('notification-1')
prepareExternalOpen.mockReset().mockReturnValue(true)
resolveTarget.mockReset().mockReturnValue(null)
storageCall.mockReset().mockResolvedValue({ success: true })
})
it('advertises only implemented methods granted by manifest permissions', () => {
expect(
getSkyPhoneAppCapabilities([
'app.close',
'device.storage',
'notifications',
'location.read',
]),
).toEqual([
'app.close',
'device.storage.get',
'device.storage.set',
'notification.create',
])
})
it('reports ready lifecycle from load only for legacy frames', () => {
expect(shouldReportCustomAppReady('legacy', 'frame-load')).toBe(true)
expect(shouldReportCustomAppReady('legacy', 'bridge-ready')).toBe(false)
expect(shouldReportCustomAppReady('sky', 'frame-load')).toBe(false)
expect(shouldReportCustomAppReady('sky', 'bridge-ready')).toBe(true)
})
it('boots LB Phone frames with their documented ready signal', () => {
expect(
getCustomAppFrameBootstrapMessages({ provider: 'lb_phone' }),
).toEqual(['componentsLoaded'])
expect(
getCustomAppFrameBootstrapMessages({ provider: '17mov' }),
).toEqual([])
})
it('passes validated storage data and server response data through', async () => {
source.capabilities = ['device.storage']
storageCall.mockResolvedValue({
data: { exists: true, revision: 4, value: { enabled: true } },
success: true,
})
const handler = createHandler()
const result = await handler.handle({
method: 'device.storage.get',
payload: { key: 'settings.main' },
requestId: 'storage-get',
})
expect(storageCall).toHaveBeenCalledWith('custom-app:storage:get', {
appId: 'source-app',
key: 'settings.main',
})
expect(result?.response).toEqual({
data: { exists: true, revision: 4, value: { enabled: true } },
requestId: 'storage-get',
success: true,
})
})
it('validates storage writes and deduplicates request ids', async () => {
source.capabilities = ['device.storage']
const handler = createHandler()
const request = {
method: 'device.storage.set',
payload: { key: 'profile', revision: 2, value: { color: 'blue' } },
requestId: 'storage-set',
}
const first = await handler.handle(request)
const duplicate = await handler.handle(request)
const invalid = await handler.handle({
method: 'device.storage.set',
payload: { key: '../profile', revision: -1, value: Number.NaN },
requestId: 'storage-invalid',
})
expect(first?.response.success).toBe(true)
expect(duplicate).toBeNull()
expect(invalid?.response).toMatchObject({
error: 'invalid_storage_request',
success: false,
})
expect(storageCall).toHaveBeenCalledWith('custom-app:storage:set', {
appId: 'source-app',
key: 'profile',
revision: 2,
value: { color: 'blue' },
})
expect(storageCall).toHaveBeenCalledOnce()
})
it('bounds the request-id deduplication window', async () => {
const handler = createHandler()
const first = await handler.handle({
method: 'unknown',
requestId: 'oldest-request',
})
for (let index = 0; index < 512; index += 1) {
await handler.handle({
method: 'unknown',
requestId: `request-${index}`,
})
}
const afterEviction = await handler.handle({
method: 'unknown',
requestId: 'oldest-request',
})
expect(first?.response.error).toBe('unsupported_method')
expect(afterEviction?.response.error).toBe('unsupported_method')
})
it('requires storage and close permissions', async () => {
const handler = createHandler()
const storage = await handler.handle({
method: 'device.storage.get',
payload: { key: 'profile' },
requestId: 'denied-storage',
})
const close = await handler.handle({
method: 'app.close',
requestId: 'denied-close',
})
expect(storage?.response.error).toBe('permission_denied')
expect(close?.response.error).toBe('permission_denied')
expect(close?.effect).toBeUndefined()
})
it('creates a small text-only notification routed to its own app', async () => {
source.capabilities = ['notifications']
const handler = createHandler()
const result = await handler.handle({
method: 'notification.create',
payload: {
sound: 'signal',
subtitle: ' Update ',
text: ' Work completed. ',
title: ' Example ',
},
requestId: 'notification',
})
expect(createNotification).toHaveBeenCalledWith({
appId: 'source-app',
route: '/apps/source-app',
sound: 'signal',
subtitle: 'Update',
text: 'Work completed.',
title: 'Example',
})
expect(result?.response.data).toEqual({
notificationId: 'notification-1',
})
})
it('rejects notification route injection and undeclared notification use', async () => {
source.capabilities = ['notifications']
const handler = createHandler()
const injected = await handler.handle({
method: 'notification.create',
payload: {
route: 'https://example.test',
text: 'Body',
title: 'Title',
},
requestId: 'notification-injected',
})
source.capabilities = []
const denied = await handler.handle({
method: 'notification.create',
payload: { text: 'Body', title: 'Title' },
requestId: 'notification-denied',
})
expect(injected?.response.error).toBe('invalid_notification')
expect(denied?.response.error).toBe('permission_denied')
expect(createNotification).not.toHaveBeenCalled()
})
it('opens only registered targets and passes JSON data to external apps', async () => {
source.capabilities = ['app.open']
resolveTarget.mockReturnValue({
external: true,
id: 'target-app',
route: '/apps/target-app',
})
const handler = createHandler()
const data = { screen: 'details', selectedId: 17 }
const result = await handler.handle({
method: 'app.open',
payload: { appId: 'target-app', data },
requestId: 'open-target',
})
expect(resolveTarget).toHaveBeenCalledWith('target-app')
expect(prepareExternalOpen).toHaveBeenCalledWith('target-app', data)
expect(result).toMatchObject({
effect: { route: '/apps/target-app', type: 'open' },
response: {
data: { appId: 'target-app' },
success: true,
},
})
})
it('rejects foreign URLs, unknown apps, and data for built-in apps', async () => {
source.capabilities = ['app.open']
const handler = createHandler()
const foreignUrl = await handler.handle({
method: 'app.open',
payload: { appId: 'target-app', url: 'https://example.test' },
requestId: 'open-url',
})
const missing = await handler.handle({
method: 'app.open',
payload: { appId: 'missing-app' },
requestId: 'open-missing',
})
resolveTarget.mockReturnValue({
external: false,
id: 'notes',
route: '/apps/notes',
})
const builtInData = await handler.handle({
method: 'app.open',
payload: { appId: 'notes', data: { noteId: 'one' } },
requestId: 'open-built-in-data',
})
expect(foreignUrl?.response.error).toBe('invalid_app_open')
expect(missing?.response.error).toBe('app_not_found')
expect(builtInData?.response.error).toBe('open_data_not_supported')
expect(prepareExternalOpen).not.toHaveBeenCalled()
})
})