mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
279 lines
8.4 KiB
TypeScript
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()
|
|
})
|
|
})
|