SPB Git

Repository Public

alerts-sw.test.ts 7.2 KB 95 lines typescript

1// The service worker's handler logic (pure part) and the built worker itself, run in a fake ServiceWorkerGlobalScope:2// a push becomes a notification in the device's language, a tap opens the right screen, nothing else is in the worker.3import { build } from 'esbuild';4import { fileURLToPath } from 'node:url';5import { describe, expect, it } from 'vitest';6import { languageOf, notificationFor, safeUrl } from '../src/sw-core.ts';78const payload = (o: Record<string, unknown> = {}) => ({ v: 1, id: 42, kind: 'transfer.confirmed', sev: 'info', ts: 1_800_000_000_000, l: 'fr', d: false, p: { network: 'Base', count: 1 }, s: { type: 'proposal', id: 'p1' }, ...o });910describe('service worker: push → notification', () => {11  it('renders in the language chosen on the device, without details by default', () => {12    expect(notificationFor(payload())).toEqual({ title: 'Transfert confirmé', options: { body: 'Sur Base.', tag: 'spw-42', lang: 'fr', icon: '/icon-192.png', badge: '/icon-192.png', timestamp: 1_800_000_000_000, data: { url: '/activity', id: 42 } } });13    expect(notificationFor(payload({ l: 'en' }))).toMatchObject({ title: 'Transfer confirmed', options: { body: 'On Base.', lang: 'en' } });14    expect(notificationFor(payload({ d: true, p: { network: 'Base', amount: '25', symbol: 'USDC' } })).options.body).toBe('25 USDC envoyés sur Base.');15    // details off: even if amounts were in the payload, the private wording has no place for them16    expect(notificationFor(payload({ d: false, p: { network: 'Base', amount: '25', symbol: 'USDC' } })).options.body).toBe('Sur Base.');17  });18  it('falls back to the browser language, then to French', () => {19    expect(languageOf(undefined, 'en-CA')).toBe('en');20    expect(languageOf(undefined, 'fr-CA')).toBe('fr');21    expect(languageOf(undefined, undefined)).toBe('fr');22    expect(languageOf('en', 'fr-CA')).toBe('en');23    expect(languageOf('de', 'en-US')).toBe('en');24    expect(notificationFor(payload({ l: undefined }), 'en-US').title).toBe('Transfer confirmed');25  });26  it('a critical alert stays on screen and opens the screen it is about', () => {27    const n = notificationFor(payload({ kind: 'agent.stopped_loss', sev: 'critical', p: { agent: 'Momentum' }, s: { type: 'agent', id: '6f1c' } }));28    expect(n).toMatchObject({ title: 'Agent arrêté : limite de perte', options: { body: 'Momentum a atteint sa limite de perte et s’est arrêté.', requireInteraction: true, data: { url: '/agents/6f1c' } } });29    expect(notificationFor(payload({ kind: 'security.signer_halted', sev: 'critical', p: {}, s: null })).options.data.url).toBe('/security');30    expect(notificationFor(payload({ kind: 'conn.down', sev: 'warning', p: { name: 'Base', minutes: 3 }, s: { type: 'network', network: 'base' } })).options).toMatchObject({ body: 'Base ne répond plus depuis 3 min.', data: { url: '/monitor' } });31    expect(notificationFor(payload({ kind: 'system.test', p: {}, s: null })).title).toBe('Notification de test');32  });33  it('always shows something: a malformed or unknown payload gives a generic notification that opens the alerts', () => {34    for (const bad of [null, undefined, 'text', 42, {}, { v: 2, kind: 'x' }, { v: 1 }]) {35      const n = notificationFor(bad, 'fr-CA');36      expect(n).toMatchObject({ title: 'SP Wallet', options: { body: 'Nouvelle alerte. Ouvrez l’application.', data: { url: '/alerts', id: null } } });37    }38    expect(notificationFor(null, 'en-GB').options.body).toBe('New alert. Open the app.');39    expect(notificationFor(payload({ kind: 'future.kind_of_a_newer_server' }))).toMatchObject({ title: 'SP Wallet', options: { data: { url: '/alerts' } } });40  });41  it('only same-origin paths are opened', () => {42    expect(safeUrl('/agents/abc')).toBe('/agents/abc');43    expect(safeUrl('/futures?v=accounts')).toBe('/futures?v=accounts');44    for (const bad of ['https://evil.example/', '//evil.example/x', 'javascript:alert(1)', '', null, 42, '/a b', '/x#<script>']) expect(safeUrl(bad)).toBe('/alerts');45  });46});4748describe('service worker: the bundled /sw.js in a fake worker scope', () => {49  type Listener = (e: any) => void;50  async function boot(language = 'fr-CA') {51    const out = await build({ entryPoints: [fileURLToPath(new URL('../src/sw.ts', import.meta.url))], bundle: true, write: false, format: 'iife', target: 'es2020' });52    const code = out.outputFiles[0]!.text, listeners = new Map<string, Listener>(), shown: { title: string; options: any }[] = [], opened: string[] = [], messages: unknown[] = [];53    let focused = 0, skipped = false, claimed = false;54    const windows: { url: string; focus: () => Promise<void>; postMessage: (m: unknown) => void }[] = [];55    const scope = {56      addEventListener: (t: string, f: Listener) => { listeners.set(t, f); }, skipWaiting: async () => { skipped = true; }, navigator: { language },57      registration: { showNotification: async (title: string, options: unknown) => { shown.push({ title, options }); } },58      clients: { claim: async () => { claimed = true; }, matchAll: async () => windows, openWindow: async (u: string) => { opened.push(u); } },59    };60    new Function('self', code)(scope);61    const fire = async (type: string, ev: Record<string, unknown> = {}) => { const waits: Promise<unknown>[] = []; listeners.get(type)!({ ...ev, waitUntil: (p: Promise<unknown>) => waits.push(p) }); await Promise.all(waits); };62    const openWindow = () => windows.push({ url: 'https://wallet.test/', focus: async () => { focused += 1; }, postMessage: (m) => messages.push(m) });63    return { code, listeners, shown, opened, messages, fire, openWindow, state: () => ({ focused, skipped, claimed }) };64  }6566  it('handles install, activate, push and notificationclick — and has no fetch handler (nothing is cached or intercepted)', async () => {67    const w = await boot();68    expect([...w.listeners.keys()].sort()).toEqual(['activate', 'install', 'notificationclick', 'push']);69    expect(w.code).not.toMatch(/caches\.|importScripts|fetch\(/);70    await w.fire('install'); await w.fire('activate');71    expect(w.state()).toMatchObject({ skipped: true, claimed: true });72  });73  it('a push shows the notification; a push that is not JSON still shows one', async () => {74    const w = await boot('en-US');75    await w.fire('push', { data: { json: () => payload({ l: undefined, kind: 'deposit.received' }) } });76    await w.fire('push', { data: { json: () => { throw new Error('not json'); } } });77    await w.fire('push', { data: null });78    expect(w.shown.map((s) => [s.title, s.options.body])).toEqual([['Deposit received', 'On Base.'], ['SP Wallet', 'New alert. Open the app.'], ['SP Wallet', 'New alert. Open the app.']]);79  });80  it('a tap focuses the open app and tells it where to go; with no window it opens one', async () => {81    const w = await boot();82    let closed = 0;83    const click = (url: unknown) => w.fire('notificationclick', { notification: { close: () => { closed += 1; }, data: { url } } });84    await click('/agents/abc');85    expect(w.opened).toEqual(['/agents/abc']);86    w.openWindow();87    await click('/security');88    await click('https://evil.example/');89    expect(w.opened).toEqual(['/agents/abc']);90    expect(w.messages).toEqual([{ type: 'spw-nav', url: '/security' }, { type: 'spw-nav', url: '/alerts' }]);91    expect(w.state().focused).toBe(2);92    expect(closed).toBe(3);93  });94});95
Files main
Menu

Theme