Repository Public
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