SPB Git

Repository Public

alerts.spec.ts 16.3 KB 210 lines typescript

1import { spawn, type ChildProcess } from 'node:child_process';2import { existsSync, mkdirSync } from 'node:fs';3import { dirname, resolve } from 'node:path';4import { fileURLToPath } from 'node:url';5import { expect, test, type Page } from '@playwright/test';67// Alerts and system status against the real API (tests/e2e/alerts-server.mts: real routes, real dispatcher loop, PostgreSQL,8// the BUILT web bundle, a stub signer, a fake push service). Chromium, notification permission granted in the context.9//10// Push: headless Chromium has no push service to subscribe to, so `PushManager.subscribe` is replaced in the page by one that11// returns the subscription of the fake push service (endpoint + keys). Everything else is real: the app registers the real12// /sw.js, posts the subscription, the API encrypts (RFC 8291) and signs (VAPID) and POSTs to the fake push service, which13// decrypts; the decrypted payload is then handed to the REAL service worker through the DevTools protocol14// (ServiceWorker.deliverPushMessage) and the notification it shows is read back from the registration.15//16//   (cd apps/web && node_modules/.bin/vite build) && npx playwright test -c tests/e2e/playwright.config.ts alerts.spec.ts17// ALERTS_SHOTS=1 writes the screenshots to docs/v6/screens/.18const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');19const SHOTS = resolve(ROOT, 'docs/v6/screens');20const SIZES = [21  { name: 'phone', port: 8681, use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },22  { name: 'desktop', port: 8682, use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },23] as const;2425// the full browser in its new headless mode: the old headless shell answers "denied" to every notification permission26test.use({ channel: 'chromium' });2728for (const size of SIZES) {29  const phone = size.name === 'phone', BASE = `http://localhost:${size.port}`;30  test.describe(`alerts and system status — ${size.name}`, () => {31    test.use({ ...size.use, locale: 'fr-CA', baseURL: BASE });32    let server: ChildProcess | null = null, log = '', token = '', stoppedAgent = '';33    test.beforeAll(async () => {34      if (!existsSync(resolve(ROOT, 'dist/web/sw.js'))) throw new Error('dist/web is missing or has no sw.js: build the web bundle first (cd apps/web && node_modules/.bin/vite build)');35      server = spawn(resolve(ROOT, 'node_modules/.bin/tsx'), ['tests/e2e/alerts-server.mts', '--port', String(size.port), '--db', `spw_v6_alerts_e2e_${size.name}`], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });36      server.stdout!.on('data', (d) => { log += String(d); }); server.stderr!.on('data', (d) => { log += String(d); });37      for (let i = 0; i < 160 && !/READY/.test(log); i++) { if (server.exitCode !== null) break; await new Promise((r) => setTimeout(r, 250)); }38      const m = /READY \S+ token (\S+) agent (\S+)/.exec(log);39      if (!m) throw new Error(`the alerts backend did not start:\n${log.slice(-2000)}`);40      token = m[1]!; stoppedAgent = m[2]!;41    });42    test.afterAll(async () => { server?.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 500)); });43    const shot = async (page: Page, name: string, fullPage = true) => { if (process.env.ALERTS_SHOTS !== '1') return; mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/alerts-${size.name}-${name}.png`, fullPage }); };44    const call = (page: Page, method: string, path: string, body?: unknown) => page.evaluate(async ([m, p, b]) => {45      const r = await fetch(`/api/v1${p}`, { method: m as string, headers: m === 'GET' ? {} : { 'x-spw-csrf': '1', 'content-type': 'application/json' }, body: m === 'GET' ? undefined : JSON.stringify(b ?? {}) });46      return r.json();47    }, [method, path, body] as const);4849    test('bell with unread count, alerts list, live update, deep link, push permission flow, service worker push, system status', async ({ page, context }) => {50      test.setTimeout(120_000);51      const errors: string[] = [];52      page.on('pageerror', (e) => errors.push(String(e)));53      page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) errors.push(m.text()); });54      if (process.env.ALERTS_DEBUG) { page.on('response', (r) => { if (r.status() >= 400) void r.text().then((t) => console.log(r.status(), r.request().method(), r.url().replace(BASE, ''), t.slice(0, 200)), () => {}); }); page.on('console', (m) => console.log('console', m.type(), m.text())); }55      await context.addCookies([{ name: 'spw', value: token, url: BASE }]);56      await context.grantPermissions(['notifications'], { origin: BASE });57      // the only stand-in: the browser's push service (see the header)58      await page.addInitScript(() => {59        const b = (s: string) => { const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/')), u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };60        let current: unknown = null;61        PushManager.prototype.getSubscription = async function () { return current as PushSubscription | null; };62        PushManager.prototype.subscribe = async function (o?: PushSubscriptionOptionsInit) {63          const s = await (await fetch('/api/v1/__e2e/subscription')).json() as { endpoint: string; p256dh: string; auth: string };64          current = { endpoint: s.endpoint, options: { applicationServerKey: o?.applicationServerKey instanceof Uint8Array ? o.applicationServerKey.buffer : o?.applicationServerKey, userVisibleOnly: true },65            getKey: (n: string) => b(n === 'p256dh' ? s.p256dh : s.auth), unsubscribe: async () => { current = null; return true; } };66          return current as PushSubscription;67        };68      });6970      // ---- the bell, on the home screen71      await page.goto('/');72      const bell = page.getByTestId('alerts-bell').filter({ visible: true }), badge = page.getByTestId('alerts-badge').filter({ visible: true });73      await expect(bell).toBeVisible();74      const u0 = await call(page, 'GET', '/alerts/unread') as { unread: number; critical: number };75      expect(u0.unread).toBeGreaterThanOrEqual(7);76      expect(u0.critical).toBe(1);77      await expect(badge).toHaveText(String(u0.unread));78      await expect(bell).toHaveAttribute('aria-label', `Alertes (${u0.unread})`);79      // the five phone tabs are untouched80      if (phone) await expect(page.getByRole('navigation').getByRole('button')).toHaveText(['Accueil', 'Échanger', 'Envoyer', 'Activité', 'Réglages']);81      await shot(page, 'home-bell', false);8283      // ---- the list84      await bell.click();85      await expect(page).toHaveURL(/\/alerts$/);86      const list = page.getByTestId('alerts-list');87      await expect(list.locator('button')).toHaveCount(u0.unread + 4);                    // already read: 3 seeded + the restart notice of this boot88      await expect(list.locator('[data-kind="agent.stopped_loss"]')).toContainText('Agent arrêté : limite de perte');89      await expect(list.locator('[data-kind="agent.stopped_loss"]')).toContainText('Critique');90      await expect(list.locator('[data-kind="mandate.expiring"]')).toContainText('Le mandat de Momentum BTC/ETH expire dans moins de 72 h.');   // produced by the real rule, not seeded91      await expect(list.locator('[data-kind="deriv.paper.filled"]')).toContainText('(papier, simulé)');92      await expect(page.getByTestId('alerts-unread')).toHaveText(`${u0.unread} non lue(s)`);93      expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);94      await shot(page, 'list');95      await page.getByRole('radio', { name: 'Critique' }).click();96      await expect(list.locator('button')).toHaveCount(1);97      await page.getByRole('radio', { name: 'Toutes' }).click();98      await page.getByRole('radio', { name: 'Système' }).click();99      await expect(list.locator('[data-kind="conn.down"]')).toBeVisible();100      await expect(list.locator('[data-kind="agent.stopped_loss"]')).toHaveCount(0);101      await page.getByRole('radio', { name: 'Tous les domaines' }).click();102103      // ---- live: an alert raised on the server appears without a reload (SSE)104      await call(page, 'POST', '/__e2e/raise', { kind: 'deposit.received', params: { network: 'Base', symbol: 'USDC', amount: '25' }, subject: { type: 'deposit', id: '0xlive', network: 'base' } });105      await expect(badge).toHaveText(String(u0.unread + 1), { timeout: 10_000 });106      await expect(list.locator('button').first()).toContainText('25 USDC reçus sur Base.');107108      // ---- deep link: the alert opens the agent it is about, and is read109      await list.locator('[data-kind="agent.stopped_loss"]').click();110      await expect(page).toHaveURL(new RegExp(`/agents/${stoppedAgent}$`));111      await expect(badge).toHaveText(String(u0.unread));112      await page.goBack();113      await expect(list.locator('[data-kind="agent.stopped_loss"]')).toHaveAttribute('data-unread', 'false');114      await page.getByRole('button', { name: 'Tout marquer comme lu' }).click();115      await expect(page.getByTestId('alerts-badge')).toHaveCount(0);116      await expect(page.getByTestId('alerts-unread')).toHaveText('0 non lue(s)');117118      // ---- settings: permission flow119      await page.getByRole('tab', { name: 'Réglages' }).click();120      await expect(page).toHaveURL(/\/alerts\?v=settings$/);121      const state = page.getByTestId('push-state');122      await expect(state).toHaveAttribute('data-state', 'off');123      await expect(page.getByTestId('push-preview')).toContainText('Sur Base.');             // details off by default: no amount in a notification124      await expect(page.getByTestId('webhook-sample')).not.toContainText('25');125      await shot(page, 'settings');126      await page.getByRole('button', { name: 'Activer les notifications' }).click();127      await expect(state).toHaveAttribute('data-state', 'on', { timeout: 15_000 });128      await expect(page.getByTestId('push-devices')).toContainText('cet appareil');129      expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration('/'))?.active?.scriptURL)).toBe(`${BASE}/sw.js`);130      expect(await page.evaluate(() => Notification.permission)).toBe('granted');131132      // ---- "send a test notification": the API encrypts and signs, the fake push service decrypts133      await page.getByTestId('push-devices').getByRole('button', { name: 'Tester' }).click();134      await expect(page.getByText('Notification de test envoyée (1).')).toBeVisible();135      type Seen = { path: string; urgency: string; vapid: boolean; payload: { kind: string; l: string; d: boolean; p: Record<string, unknown> } };136      let pushes = await call(page, 'GET', '/__e2e/pushes') as Seen[];137      expect(pushes).toHaveLength(1);138      expect(pushes[0]).toMatchObject({ path: '/push/e2e-device', urgency: 'normal', vapid: true, payload: { v: 1, kind: 'system.test', l: 'fr', d: false } });139140      // ---- a real alert: pushed without its amount, then shown by the REAL service worker in the device's language141      await call(page, 'POST', '/__e2e/raise', { kind: 'deposit.received', params: { network: 'Base', symbol: 'ETH', amount: '1.5' }, subject: { type: 'deposit', id: '0xpush', network: 'base' } });142      await expect.poll(async () => (await call(page, 'GET', '/__e2e/pushes') as Seen[]).length).toBe(2);143      pushes = await call(page, 'GET', '/__e2e/pushes') as Seen[];144      expect(pushes[1]!.payload).toMatchObject({ kind: 'deposit.received', d: false, p: { network: 'Base' } });145      expect(JSON.stringify(pushes[1]!.payload)).not.toMatch(/1\.5|ETH/);146      const cdp = await context.newCDPSession(page);147      const registrationId = await new Promise<string>((done) => {148        cdp.on('ServiceWorker.workerRegistrationUpdated', (e: { registrations: { registrationId: string; scopeURL: string }[] }) => { const r = e.registrations.find((x) => x.scopeURL.startsWith(BASE)); if (r) done(r.registrationId); });149        void cdp.send('ServiceWorker.enable');150      });151      for (const p of pushes) await cdp.send('ServiceWorker.deliverPushMessage', { origin: BASE, registrationId, data: JSON.stringify(p.payload) });152      const shown = () => page.evaluate(async () => (await (await navigator.serviceWorker.getRegistration('/'))!.getNotifications()).map((n) => ({ title: n.title, body: n.body, lang: n.lang, url: (n.data as { url: string }).url })));153      await expect.poll(shown).toEqual(expect.arrayContaining([154        { title: 'Notification de test', body: 'Les notifications fonctionnent sur cet appareil.', lang: 'fr', url: '/monitor' },155        { title: 'Dépôt reçu', body: 'Sur Base.', lang: 'fr', url: '/activity' }]));156157      // ---- "show details": the owner's choice, visible before it applies158      await page.getByRole('switch', { name: 'Afficher les détails dans les notifications' }).click();159      await expect(page.getByTestId('push-preview')).toContainText('25 USDC envoyés sur Base.');160      await call(page, 'POST', '/__e2e/raise', { kind: 'deposit.received', params: { network: 'Base', symbol: 'ETH', amount: '2' }, subject: { type: 'deposit', id: '0xpush2', network: 'base' } });161      await expect.poll(async () => (await call(page, 'GET', '/__e2e/pushes') as Seen[]).length).toBe(3);162      expect((await call(page, 'GET', '/__e2e/pushes') as Seen[])[2]!.payload).toMatchObject({ d: true, p: { amount: '2', symbol: 'ETH' } });163      await page.getByRole('switch', { name: 'Afficher les détails dans les notifications' }).click();164      await expect(page.getByTestId('push-preview')).toContainText('Sur Base.');165166      // a rule can be switched off167      await page.getByRole('button', { name: /^Transferts/ }).click();168      await page.getByRole('switch', { name: 'Dépôt reçu' }).click();169      await expect(page.getByRole('switch', { name: 'Dépôt reçu' })).toHaveAttribute('aria-checked', 'false');170      expect(await call(page, 'POST', '/__e2e/raise', { kind: 'deposit.received', params: { network: 'Base' } })).toMatchObject({ made: false });171      await page.getByRole('switch', { name: 'Dépôt reçu' }).click();172      await shot(page, 'settings-push-on');173174      // removing this device unsubscribes it175      await page.getByTestId('push-devices').getByRole('button', { name: 'Retirer' }).click();176      await expect(state).toHaveAttribute('data-state', 'off');177      expect(await call(page, 'GET', '/push/subscriptions')).toEqual([]);178179      // ---- system status180      if (phone) { await page.getByRole('navigation').getByRole('button', { name: 'Réglages' }).click(); await page.getByRole('button', { name: 'État du système' }).click(); }181      else await page.getByRole('button', { name: 'État du système' }).click();182      await expect(page).toHaveURL(/\/monitor$/);183      const mon = page.getByTestId('monitor-screen');184      await expect(mon.getByText('Sources en panne : Polygon')).toBeVisible();185      await expect(page.getByTestId('monitor-capital')).toContainText('Coffres d’agents');186      await expect(page.getByTestId('monitor-capital')).toContainText(/538,75\s\$/);         // LIVE vault, last snapshot187      await expect(page.getByTestId('monitor-capital')).toContainText(/10\s184,90\s\$/);     // paper account: shown, tagged simulated188      await expect(mon.getByText('Trois choses différentes, jamais additionnées')).toBeVisible();189      await expect(page.getByTestId('monitor-pnl')).toContainText('Momentum BTC/ETH');190      await expect(page.getByTestId('monitor-pnl')).toContainText(/\+26,35\s\$/);            // 7 d: 538.75 − 512.40191      await expect(page.getByTestId('monitor-connectors')).toContainText('Signataire');192      await expect(page.getByTestId('monitor-connectors')).toContainText('Polygon');193      await expect(page.getByTestId('monitor-loops')).toContainText('alerts');194      await expect(mon.getByText('e2e-demo-key')).toBeVisible();195      await expect(mon.getByText(/intact \(\d+ événements\)/)).toBeVisible();196      expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);197      await shot(page, 'monitor');198199      // English200      await page.evaluate(() => localStorage.setItem('spw-locale', 'en'));201      await page.goto('/alerts');202      await expect(page.getByRole('heading', { name: 'Alerts' })).toBeVisible();203      await expect(page.getByTestId('alerts-list').locator('[data-kind="agent.stopped_loss"]')).toContainText('Agent stopped: loss limit');204      await shot(page, 'list-en');205      await page.evaluate(() => localStorage.setItem('spw-locale', 'fr'));206      expect(errors).toEqual([]);207    });208  });209}210
Files main
Menu

Theme