// E2E for the per-node Reach page (#/nodes//reach). // Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL. const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:3000'; async function getJson(page, url) { const resp = await page.request.get(url); if (!resp.ok()) throw new Error('GET ' + url + ' → HTTP ' + resp.status()); return resp.json(); } (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); // A repeater is most likely to have reach data (it relays). const nodes = await getJson(page, BASE + '/api/nodes?role=repeater&limit=1'); if (!nodes.nodes || !nodes.nodes.length) { console.log('node-reach E2E SKIP (no repeater in dataset)'); await browser.close(); return; } const pk = nodes.nodes[0].public_key; // 1. The endpoint returns the documented shape. const reach = await getJson(page, BASE + '/api/nodes/' + pk + '/reach?days=7'); for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) { if (!(k in reach)) throw new Error('reach response missing key: ' + k); } if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array'); // 2. The page renders. await page.goto(BASE + '/#/nodes/' + pk + '/reach'); await page.waitForSelector('.nq-head', { timeout: 20000 }); if (!(await page.locator('h2', { hasText: 'Reach' }).count())) { throw new Error('Reach header missing'); } // 3. If this node is identifiable, exercise the table, toggles and links. if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) { await page.waitForSelector('#nqIncoming'); await page.waitForSelector('#nqOutgoing'); // Derive the EXACT expected row counts from the API so the toggles are // verified, not just "didn't shrink" (tautology). Base shows two-way only; // incoming adds we-only links; +outgoing adds the rest (= all links). const twoWayExp = reach.links.filter(l => l.bidir).length; const weOnlyExp = reach.links.filter(l => !l.bidir && l.we_hear > 0 && l.they_hear === 0).length; const allExp = reach.links.length; const base = await page.locator('#nqRows tr').count(); if (base !== twoWayExp) throw new Error(`base rows ${base} != two-way ${twoWayExp}`); await page.check('#nqIncoming'); const withIncoming = await page.locator('#nqRows tr').count(); if (withIncoming !== twoWayExp + weOnlyExp) { throw new Error(`incoming rows ${withIncoming} != two-way+we-only ${twoWayExp + weOnlyExp}`); } await page.check('#nqOutgoing'); const withBoth = await page.locator('#nqRows tr').count(); if (withBoth !== allExp) throw new Error(`both-toggles rows ${withBoth} != all links ${allExp}`); // Neighbour rows link to a node detail page. if (await page.locator('#nqRows a.nq-link').count()) { const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href'); if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href); } // Leaflet puts .leaflet-container on the element you hand L.map(), so the // class lands on #nqMap itself. The old selector had a space in it and so // waited for a DESCENDANT carrying the class, which never exists: that is // why this suite timed out here every time it was run. // // The condition is the node's own coordinates, not a link's: // public/node-reach.js calls NodeReachMap.render only when n.lat != null, // so a node with positioned neighbours but no position of its own has no // map to wait for. if (reach.node && reach.node.lat != null && reach.node.lon != null) { await page.waitForSelector('#nqMap.leaflet-container', { timeout: 10000 }); } } // 4. A busy server (429 + Retry-After) is retried by the reach page itself: // a "retrying" hint, then the report, with no error. The reach requests are // answered by a route so the server does not have to be made busy. const reachPath = '/api/nodes/' + pk + '/reach'; async function withReachRoute(answer, fn) { const p = await browser.newPage(); const seen = { n: 0, at: [] }; await p.route((url) => new URL(url).pathname === reachPath, async (route) => { seen.n++; seen.at.push(Date.now()); const a = await answer(seen.n); if (a === 'pass') return route.continue(); return route.fulfill(a); }); try { await fn(p, seen); } finally { await p.close(); } } const busy = { status: 429, headers: { 'Retry-After': '1', 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'reach is busy computing other reports', retryAfter: 1 }) }; await withReachRoute((n) => (n <= 2 ? busy : 'pass'), async (p, seen) => { await p.goto(BASE + '/#/nodes/' + pk + '/reach'); await p.waitForSelector('.nq-busy', { timeout: 10000 }); const hint = await p.locator('.nq-busy').textContent(); if (!hint.includes('retrying in 1 s')) throw new Error('busy hint text: ' + hint); await p.waitForSelector('.nq-head', { timeout: 20000 }); if (await p.locator('.nq-error').count()) throw new Error('error shown after a successful retry'); if (seen.n !== 3) throw new Error('reach requests ' + seen.n + ' != 3 (2 busy + 1 served)'); // Two waits of Retry-After: 1 s each between the first and third request. const gap = seen.at[2] - seen.at[0]; if (gap < 1800) throw new Error('retries did not wait for Retry-After: ' + gap + ' ms for two retries'); }); // 5. A server that stays busy: 5 retries, then the error. await withReachRoute(() => busy, async (p, seen) => { await p.goto(BASE + '/#/nodes/' + pk + '/reach'); await p.waitForSelector('.nq-error', { timeout: 20000 }); const msg = await p.locator('.nq-error').textContent(); if (!msg.includes('API 429')) throw new Error('give-up error text: ' + msg); if (seen.n !== 6) throw new Error('reach requests ' + seen.n + ' != 6 (1 + 5 retries)'); }); // 6. Leaving the page while it waits stops the retries. await withReachRoute(() => busy, async (p, seen) => { await p.goto(BASE + '/#/nodes/' + pk + '/reach'); await p.waitForSelector('.nq-busy', { timeout: 10000 }); const atLeave = seen.n; await p.evaluate(() => { location.hash = '#/nodes'; }); await p.waitForTimeout(2500); if (seen.n !== atLeave) throw new Error('reach retried after leaving the page: ' + atLeave + ' → ' + seen.n); }); // 6b. Leaving while the request is still in flight: the late 429 must not // start a retry or write the hint into the page that replaced this one. await withReachRoute(async () => { await new Promise((r) => setTimeout(r, 1000)); return busy; }, async (p, seen) => { await p.goto(BASE + '/#/nodes/' + pk + '/reach'); await p.waitForFunction(() => document.querySelector('.nq-load') !== null, null, { timeout: 10000 }); await p.evaluate(() => { location.hash = '#/nodes'; }); await p.waitForTimeout(2500); if (await p.locator('.nq-busy').count()) throw new Error('busy hint written after leaving the page'); if (seen.n !== 1) throw new Error('reach retried after leaving mid-request: ' + seen.n + ' requests'); }); // 7. Other errors are not retried. await withReachRoute(() => ({ status: 500, body: '{"error":"reach computation failed"}', headers: { 'Retry-After': '1' } }), async (p, seen) => { await p.goto(BASE + '/#/nodes/' + pk + '/reach'); await p.waitForSelector('.nq-error', { timeout: 10000 }); await p.waitForTimeout(1500); if (seen.n !== 1) throw new Error('a 500 was retried: ' + seen.n + ' requests'); }); console.log('node-reach E2E OK'); await browser.close(); })().catch((e) => { console.error(e); process.exit(1); });