Files
TREK/client/src/native/nativeApp.test.ts
T
Maurice f35b189ca2 feat(native): home-screen widget and travel-day Live Activity on iOS
A WidgetKit extension (TREKWidgets) with two parts: the next trip on the home
screen, with a countdown before it starts and today's day and next stops
while it runs, and a Live Activity for the travel day on the lock screen and
in the Dynamic Island showing the next timed stop and the day's progress.

The web app builds both from the trips stored offline on the device
(native/widgets.ts) and hands them over through TrekShell
(setWidgetSnapshot, syncTripDay, endTripDay). The texts are already
translated, so the widget needs no localisation of its own. The snapshot
lives in the keychain group the app shares with its extensions, so no App
Group is needed. Taps open com.liketrek.trek://navigate/<path>.

NativeWidgetsSync refreshes on start, on returning to the foreground and every
quarter hour. Both parts are gated behind the widgets / liveActivity
capabilities, so older binaries and the PWA are unaffected.
2026-11-06 15:02:55 +01:00

173 lines
7.0 KiB
TypeScript

import { App } from '@capacitor/app';
import { Keyboard } from '@capacitor/keyboard';
import { SplashScreen } from '@capacitor/splash-screen';
import { StatusBar, Style } from '@capacitor/status-bar';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeTopOverlay } from '../mobile/overlayStack';
import { isEffectivelyOffline } from '../sync/networkMode';
import { checkAppUpdate } from './capabilities';
import { installNativeGeolocation } from './geolocation';
import { scrollToTop, startNativeApp } from './nativeApp';
import { keepOfflineShellFresh } from './offlineShell';
import { isNativeApp, isNativeIos } from './platform';
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(), minimizeApp: vi.fn() } }));
vi.mock('@capacitor/keyboard', () => ({ Keyboard: { setAccessoryBarVisible: vi.fn() } }));
vi.mock('@capacitor/splash-screen', () => ({ SplashScreen: { hide: vi.fn() } }));
vi.mock('@capacitor/status-bar', () => ({ StatusBar: { setStyle: vi.fn() }, Style: { Dark: 'DARK', Light: 'LIGHT' } }));
vi.mock('./platform', () => ({ isNativeApp: vi.fn(), isNativeIos: vi.fn() }));
vi.mock('./offlineShell', () => ({ keepOfflineShellFresh: vi.fn() }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() }));
vi.mock('./capabilities', () => ({
checkAppUpdate: vi.fn(async () => false),
loadNativeCapabilities: vi.fn(async () => ({})),
}));
vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() }));
vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) }));
vi.mock('./push', () => ({ startNativePush: vi.fn(async () => {}), openPushTarget: vi.fn() }));
vi.mock('../mobile/overlayStack', () => ({ closeTopOverlay: vi.fn(() => false) }));
function backButtonHandler(): (event: { canGoBack: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'backButton');
if (!call) throw new Error('no backButton listener');
return call[1] as unknown as (event: { canGoBack: boolean }) => void;
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(isNativeIos).mockReturnValue(false);
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
vi.mocked(keepOfflineShellFresh).mockResolvedValue();
document.documentElement.classList.remove('dark');
});
afterEach(() => {
vi.useRealTimers();
});
describe('startNativeApp', () => {
it('does nothing in a browser', () => {
vi.mocked(isNativeApp).mockReturnValue(false);
startNativeApp();
expect(App.addListener).not.toHaveBeenCalled();
expect(StatusBar.setStyle).not.toHaveBeenCalled();
});
it("Android's back gesture closes an open sheet before it leaves the page", () => {
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
vi.mocked(closeTopOverlay).mockReturnValueOnce(true);
startNativeApp();
backButtonHandler()({ canGoBack: true });
expect(back).not.toHaveBeenCalled();
backButtonHandler()({ canGoBack: true });
expect(back).toHaveBeenCalledTimes(1);
back.mockRestore();
});
it('puts the app location under navigator.geolocation once the binary has answered', async () => {
vi.useRealTimers();
startNativeApp();
await vi.waitFor(() => expect(installNativeGeolocation).toHaveBeenCalled());
});
it('a status bar tap scrolls the list in the middle of the screen back to the top', () => {
const list = document.createElement('div');
list.style.overflowY = 'auto';
Object.defineProperty(list, 'scrollTop', { configurable: true, value: 300 });
list.scrollTo = vi.fn();
document.body.appendChild(list);
document.elementFromPoint = vi.fn(() => list);
scrollToTop();
expect(list.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
list.remove();
});
it('asks the binary what it can do and the server which binary it needs', () => {
startNativeApp();
expect(checkAppUpdate).toHaveBeenCalledWith(expect.any(Function));
});
it('hides the splash after the first frame', () => {
startNativeApp();
vi.runOnlyPendingTimers();
expect(SplashScreen.hide).toHaveBeenCalled();
});
it('walks back through history before it leaves the app', () => {
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
startNativeApp();
backButtonHandler()({ canGoBack: true });
expect(back).toHaveBeenCalled();
backButtonHandler()({ canGoBack: false });
expect(App.minimizeApp).toHaveBeenCalled();
});
it('keeps the status bar icons readable when the scheme changes', async () => {
startNativeApp();
expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Light });
document.documentElement.classList.add('dark');
await vi.waitFor(() => expect(StatusBar.setStyle).toHaveBeenLastCalledWith({ style: Style.Dark }));
});
it('hides the keyboard accessory bar on iOS only', () => {
startNativeApp();
expect(Keyboard.setAccessoryBarVisible).not.toHaveBeenCalled();
vi.mocked(isNativeIos).mockReturnValue(true);
startNativeApp();
expect(Keyboard.setAccessoryBarVisible).toHaveBeenCalledWith({ isVisible: false });
});
it('refreshes the offline start on iOS once the first screen has settled', () => {
vi.mocked(isNativeIos).mockReturnValue(true);
startNativeApp();
expect(keepOfflineShellFresh).not.toHaveBeenCalled();
vi.advanceTimersByTime(15_000);
expect(keepOfflineShellFresh).toHaveBeenCalled();
});
it('skips the refresh while offline, and never on Android', () => {
vi.mocked(isNativeIos).mockReturnValue(true);
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
startNativeApp();
vi.advanceTimersByTime(15_000);
expect(keepOfflineShellFresh).not.toHaveBeenCalled();
vi.mocked(isNativeIos).mockReturnValue(false);
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
startNativeApp();
vi.advanceTimersByTime(15_000);
expect(keepOfflineShellFresh).not.toHaveBeenCalled();
});
it('logs a failed refresh instead of throwing', async () => {
vi.mocked(isNativeIos).mockReturnValue(true);
vi.mocked(keepOfflineShellFresh).mockRejectedValue(new Error('down'));
const report = vi.fn();
vi.stubGlobal('reportError', report);
startNativeApp();
vi.advanceTimersByTime(15_000);
await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.any(Error)));
vi.unstubAllGlobals();
});
it('stays quiet when the installed app is older than the offline copy', async () => {
vi.mocked(isNativeIos).mockReturnValue(true);
vi.mocked(keepOfflineShellFresh).mockRejectedValue(
Object.assign(new Error('not implemented'), { code: 'UNIMPLEMENTED' })
);
const report = vi.fn();
vi.stubGlobal('reportError', report);
startNativeApp();
vi.advanceTimersByTime(15_000);
await vi.waitFor(() => expect(keepOfflineShellFresh).toHaveBeenCalled());
// Let the rejection reach its handler.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(report).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});