Files
TREK/client/src/native/capabilities.test.ts
T
Maurice f92aea80a7 feat(native): a capability handshake between the app binary and the server's UI
The UI ships with the server and the binary through the stores, so the two
drift. TrekShell.getCapabilities now reports the binary's shell API, version
and native features by name (iOS and Android), and the UI turns each native
feature on only where the binary has it; a binary from before the handshake
counts as shell API 1 with none. The other way round, the server names the
oldest binary its UI works with (minNativeShellApi), and an app below it is
asked to update with a link to its store.
2026-11-06 15:02:52 +01:00

78 lines
2.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetNativeCapabilities,
appUpdateRequired,
checkAppUpdate,
hasNativeFeature,
loadNativeCapabilities,
nativeCapabilities,
} from './capabilities';
import { isNativeApp } from './platform';
import { TrekShell } from './trekShell';
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: { getCapabilities: vi.fn() },
isUnimplemented: (err: unknown) => (err as { code?: string })?.code === 'UNIMPLEMENTED',
}));
const CAPS = { shellApi: 2, platform: 'ios', appVersion: '5.2.0', build: '12', features: ['capabilities', 'edgeBack'] };
beforeEach(() => {
_resetNativeCapabilities();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(TrekShell.getCapabilities)
.mockReset()
.mockResolvedValue(CAPS as never);
});
afterEach(() => vi.unstubAllGlobals());
describe('native capabilities', () => {
it('asks the binary once and answers features by name', async () => {
await loadNativeCapabilities();
await loadNativeCapabilities();
expect(TrekShell.getCapabilities).toHaveBeenCalledTimes(1);
expect(hasNativeFeature('edgeBack')).toBe(true);
expect(hasNativeFeature('tileCache')).toBe(false);
expect(nativeCapabilities().appVersion).toBe('5.2.0');
});
it('counts a binary from before the handshake as shell API 1 with no features', async () => {
vi.mocked(TrekShell.getCapabilities).mockRejectedValue({ code: 'UNIMPLEMENTED' });
const caps = await loadNativeCapabilities();
expect(caps.shellApi).toBe(1);
expect(hasNativeFeature('capabilities')).toBe(false);
});
it('reports a real failure but still settles on the legacy answer', async () => {
const report = vi.fn();
vi.stubGlobal('reportError', report);
vi.mocked(TrekShell.getCapabilities).mockRejectedValue(new Error('bridge down'));
expect((await loadNativeCapabilities()).shellApi).toBe(1);
expect(report).toHaveBeenCalled();
});
it('asks nothing in a browser', async () => {
vi.mocked(isNativeApp).mockReturnValue(false);
expect((await loadNativeCapabilities()).platform).toBe('web');
expect(TrekShell.getCapabilities).not.toHaveBeenCalled();
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 9 }))).toBe(false);
});
it('asks for an update only when the server needs a newer binary', async () => {
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 2 }))).toBe(false);
expect(await checkAppUpdate(async () => ({ minNativeShellApi: 3 }))).toBe(true);
expect(appUpdateRequired()).toBe(true);
});
it('requires nothing from a server that names no minimum, or cannot be asked', async () => {
expect(await checkAppUpdate(async () => ({}))).toBe(false);
expect(
await checkAppUpdate(async () => {
throw new Error('offline');
})
).toBe(false);
});
});