From 8a889b61e5506e4c3a008148851590b8d9e2a0b8 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:07:10 +0200 Subject: [PATCH 1/2] test(rx-coverage): load the real page for the leaderboard escape test (#170) test-rx-coverage-escape.js sliced the row builder out of public/rx-coverage.js by marker strings. The end marker no longer exists, so it failed with "could not locate row-builder end" at every commit since it was added and was never wired into CI. Rewrite it to load the real public/rx-coverage.js in a vm (like test-issue-124-rx-coverage-viewport.js) with the real escapeHtml from public/app.js, mount the page, answer /api/rx-leaderboard with hostile observers and strictly parse the rendered #rxBoard rows. Names with ', + 'Mob "quoted" name', + "O'Brien's van", + 'Tom & Jerry', + '">', + "'>", +]; +const obs = (pubkey, name, n) => ({ pubkey, name, score: n, cells: n, nodes: n, receptions: n }); + +(async () => { + console.log('--- test-rx-coverage-escape.js ---'); + + await test('1. hostile observer names are escaped in the label, data-name and aria-label', async () => { + const observers = HOSTILE_NAMES.map((nm, i) => obs('aabbccddee' + i, nm, 10 - i)); + const { html, esc } = await renderBoard(observers); + assert(!/ { + const o = observers.find((x) => x.pubkey === r.attrs['data-rx']); + assert(o, 'row has no matching observer: ' + JSON.stringify(r.attrs)); + assert.strictEqual(r.label, esc(o.name), 'label not escaped'); + assert.strictEqual(r.attrs['data-name'], esc(o.name), 'data-name not escaped'); + assert.strictEqual(r.attrs['aria-label'], 'Show coverage for ' + esc(o.name), 'aria-label not escaped'); + assert(!/['"<>]/.test(r.label + r.attrs['data-name'] + r.attrs['aria-label']), 'raw quote or bracket survived: ' + JSON.stringify(r)); + }); + // spot-check the exact escaped forms + assert(html.includes('<script>alert(1)</script>'), ' name not escaped'); + }); + + await test('2. a hostile pubkey is escaped in data-rx and in the unnamed label fallback', async () => { + const evil = '">'; + const observers = [obs(evil, '', 2), obs("x'&", 'Named', 1)]; + const { html, esc } = await renderBoard(observers); + assert(!//i.test(html), 'raw tag injected into the leaderboard: ' + html); + assertNoBareAmp(html); + const rows = parseRows(html); + assert.strictEqual(rows.length, 2); + const unnamed = rows.find((r) => r.attrs['data-rx'] === esc(evil)); + assert(unnamed, 'data-rx does not carry the escaped pubkey: ' + html); + assert.strictEqual(unnamed.label, esc(evil.slice(0, 10)) + '…', 'unnamed label fallback not escaped'); + assert.strictEqual(unnamed.attrs['aria-label'], 'Show coverage for ' + esc(evil.slice(0, 10)), 'aria-label pubkey fallback not escaped'); + const named = rows.find((r) => r.attrs['data-name'] === 'Named'); + assert(named && named.attrs['data-rx'] === esc("x'&"), 'named row data-rx not escaped: ' + html); + }); + + await test('3. a selected hostile observer (rx= in the URL) is still escaped', async () => { + const pk = 'abc">'; + const { html, esc } = await renderBoard([obs(pk.toLowerCase(), 'sel', 1)], '#/rx-coverage?rx=' + encodeURIComponent(pk)); + assert(!//i.test(html), 'raw tag injected into the selected row: ' + html); + const rows = parseRows(html); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].attrs.class, 'rxb-row sel', 'the observer from the URL is not selected'); + assert.strictEqual(rows[0].attrs['aria-pressed'], 'true'); + assert.strictEqual(rows[0].label, esc('sel')); + }); + + await test('4. rows stay keyboard-operable (role, tabindex, aria-pressed)', async () => { + const { html } = await renderBoard([obs('aabbccddee', 'Mob', 1)]); + const r = parseRows(html)[0]; + assert.strictEqual(r.attrs.role, 'button'); + assert.strictEqual(r.attrs.tabindex, '0'); + assert.strictEqual(r.attrs['aria-pressed'], 'false'); + }); -// Malicious pubkey that would break out of the data-rx attribute and inject a -// tag if interpolated raw. With escaping, no raw '<', '>' or attribute-closing -// '"' survives. -const evil = '">'; - -// Case 1: no name → pubkey used as the visible label fallback too. -const row1 = renderRow({ pubkey: evil, name: '', receptions: 1, nodes: 1 }); -assert.ok(row1.indexOf('. -assert.ok(/role="button"/.test(row2), 'row must have role="button"'); -assert.ok(/tabindex="0"/.test(row2), 'row must be focusable (tabindex)'); -assert.ok(/aria-pressed="(true|false)"/.test(row2), 'row must expose aria-pressed'); - -console.log('rx-coverage pubkey escaping + row a11y OK'); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); From 4cfe4cc8862907d9de361d0c5688131c0224187e Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:11:06 +0200 Subject: [PATCH 2/2] test(live): port the PR #1490 canvas animation E2E to plain playwright (#170) test-pr-1490-live-map-gpu-animations-e2e.js required @playwright/test, which is not a dependency, so it could not load and was never run in CI. Its checks are not covered elsewhere: test-issue-1599-replay-freeze-e2e.js only drains one animation after a replay handoff, and test-live-anims.js is a source-string check that is not in CI. Port it to the plain playwright API used by the other *-e2e.js files (chromium.launch, local step helper, CHROMIUM_REQUIRE). Per viewport (desktop and mobile) it waits for an idle engine, fires 20 lines through window._liveDrawAnimatedLine and checks that the canvas is on the animations pane, the burst queues and wakes the engine, drains, keeps recentPaths at most 5 in the frame it drains, sleeps again, and that the fading polylines render on the animations pane. The drain is driven by awaiting rAF in the page, as test-issue-1599 does. The old fade-pane assertion was satisfied by the animation canvas itself; it now looks for the Leaflet renderer canvas the fades create there. Register it in the Playwright E2E step after test-issue-1599. Relates to #170 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-pr-1490-live-map-gpu-animations-e2e.js | 221 +++++++++++++------- 2 files changed, 150 insertions(+), 72 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22fa89c05..4472efec9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -541,6 +541,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-fullscreen-1572-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1599-replay-freeze-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-pr-1490-live-map-gpu-animations-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m1-icons-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m2-icons-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m3-icons-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-pr-1490-live-map-gpu-animations-e2e.js b/test-pr-1490-live-map-gpu-animations-e2e.js index 130f21625..4295b8a61 100644 --- a/test-pr-1490-live-map-gpu-animations-e2e.js +++ b/test-pr-1490-live-map-gpu-animations-e2e.js @@ -1,86 +1,163 @@ -const { test, expect } = require('@playwright/test'); +/** + * E2E (PR #1490 / #1514 / #1520, ported for #170): the Live map's canvas + * animation engine queues, drains and goes back to sleep. + * + * live.js draws in-flight packets on a inside a dedicated Leaflet + * pane (`animationsPane`, z=625) and runs a requestAnimationFrame loop only + * while there is something to animate. When an animation finishes it hands + * off to a fading Leaflet polyline on the same pane and records it in + * `recentPaths`, which is capped at 5. + * + * Per viewport (desktop and mobile) the test loads /#/live, waits until init + * has finished (window._liveWSHandler() returns a handler) and the engine is + * idle, then fires a burst of 20 synthetic lines through the real + * window._liveDrawAnimatedLine seam and checks, via window._liveTestSeams: + * 1. the animation canvas lives on the animations pane; + * 2. all 20 animations are queued and the engine wakes; + * 3. they drain to 0, and in the frame they drain recentPaths is <= 5 + * (20 > 5, so the cap actually runs); + * 4. the engine goes back to sleep (isAnimating false); + * 5. the fading polylines are drawn on the animations pane: with + * preferCanvas:true Leaflet adds a renderer canvas to that pane. + * + * Headless Chromium may throttle requestAnimationFrame, so the drain is + * driven by awaiting rAF from inside the page (as in + * test-issue-1599-replay-freeze-e2e.js), which also lets step 3 read the + * path count in the same frame the queue empties. + * + * Originally written against @playwright/test, which is not a dependency; + * this port uses the plain `playwright` API like the other *-e2e.js files. + * + * Usage: BASE_URL=http://localhost:13581 node test-pr-1490-live-map-gpu-animations-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); -test.describe('Live Map Canvas Animation Engine', () => { - test('canvas initializes and animations drain correctly', async ({ page }) => { - // 1. Load the map route - await page.goto('/#/live'); +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const BURST = 20; // > the recentPaths cap of 5 +const PATH_CAP = 5; +// One animation takes ~660ms at 1x. The bound is generous for slow CI +// runners and the instrumented frontend; the measured time is logged. +const DRAIN_TIMEOUT_MS = 5000; +const VIEWPORTS = [ + { name: 'desktop', viewport: { width: 1400, height: 900 } }, + { name: 'mobile', viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true }, +]; - // Ensure the map container has loaded - const mapContainer = page.locator('#liveMap'); - await expect(mapContainer).toBeVisible(); +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } - // 2. Assert the element exists - // The animation canvas is appended to the dedicated `animationsPane` - // (#1514 S6 — disambiguate from Leaflet's own preferCanvas:true renderer - // that lives on overlayPane and would otherwise be matched by `canvas`.first()). - const animCanvas = mapContainer.locator('.leaflet-pane.leaflet-animations-pane canvas'); - await expect(animCanvas).toBeAttached(); +async function runViewport(browser, vp) { + console.log('\n--- ' + vp.name + ' ' + vp.viewport.width + 'x' + vp.viewport.height + ' ---'); + const ctx = await browser.newContext({ viewport: vp.viewport, isMobile: !!vp.isMobile, hasTouch: !!vp.hasTouch }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + try { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveMap', { state: 'visible' }); + await page.waitForFunction(() => typeof window._liveWSHandler === 'function' && !!window._liveWSHandler() && + !!window._liveTestSeams && typeof window._liveDrawAnimatedLine === 'function'); + // Start from an idle engine in LIVE mode at 1x. + await page.evaluate(() => { if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE'); }); + await page.waitForFunction(() => window._liveTestSeams.getAnimCount() === 0 && !window._liveTestSeams.isAnimating(), + null, { polling: 'raf' }); - // 3. Fire synthetic packets - // #1514 S5 — bumped from 5 to 20 so the `recentPaths.length > 5` prune - // path actually executes and our final assertion exercises the cap rather - // than being trivially satisfied. - const packetCount = 20; - await page.evaluate((count) => { - // Ensure the VCR speed is at standard 1x for predictable timing - if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE'); + await step(vp.name + ': the animation canvas is on the animations pane', async () => { + const n = await page.locator('#liveMap .leaflet-pane.leaflet-animations-pane > canvas').count(); + assert(n >= 1, 'no on .leaflet-animations-pane (got ' + n + ')'); + const z = await page.evaluate(() => getComputedStyle(document.querySelector('#liveMap .leaflet-animations-pane')).zIndex); + assert(z === '625', 'animations pane z-index is ' + z + ', want 625'); + }); + const queued = await page.evaluate((count) => { for (let i = 0; i < count; i++) { - window._liveDrawAnimatedLine( - [37.4, -122.0], - [37.5, -122.1], - '#00ff00', - null, - null, - '00AA', - 'test-hash-' + i - ); + window._liveDrawAnimatedLine([37.4, -122.0], [37.5, -122.1], '#00ff00', null, null, '00AA', 'test-1490-' + i); + } + return { count: window._liveTestSeams.getAnimCount(), awake: window._liveTestSeams.isAnimating() }; + }, BURST); + + await step(vp.name + ': a burst of ' + BURST + ' animations is queued and wakes the engine', async () => { + assert(queued.count === BURST, 'queued ' + queued.count + ', want ' + BURST); + assert(queued.awake === true, 'engine did not wake (isAnimating=' + queued.awake + ')'); + }); + + const drained = await page.evaluate(async (timeoutMs) => { + const seam = window._liveTestSeams; + const t0 = performance.now(); + while (seam.getAnimCount() > 0 && performance.now() - t0 < timeoutMs) { + await new Promise((r) => requestAnimationFrame(r)); } - }, packetCount); + // Read the path count in the frame the queue emptied, before the + // fades (~0.4s) remove the paths on their own. + return { count: seam.getAnimCount(), paths: seam.getPathCount(), ms: Math.round(performance.now() - t0) }; + }, DRAIN_TIMEOUT_MS); - // Verify the packets successfully pushed to the active array - let initialAnimCount = await page.evaluate(() => window._liveTestSeams.getAnimCount()); - expect(initialAnimCount).toBe(packetCount); + await step(vp.name + ': the animations drain to 0 (' + drained.ms + 'ms)', async () => { + assert(drained.count === 0, 'activeAnimations did not drain within ' + DRAIN_TIMEOUT_MS + 'ms (count=' + drained.count + ')'); + }); - // Verify the engine woke up - let isAwake = await page.evaluate(() => window._liveTestSeams.isAnimating()); - expect(isAwake).toBe(true); + await step(vp.name + ': recentPaths is capped at ' + PATH_CAP + ' when the burst lands', async () => { + assert(drained.paths >= 1, 'no fading paths were recorded (got ' + drained.paths + ')'); + assert(drained.paths <= PATH_CAP, 'recentPaths grew to ' + drained.paths + ', cap is ' + PATH_CAP); + }); - // 4. Assert activeAnimations drains within 2x duration - // Base duration is 660ms at 1x speed. 2x duration = 1320ms. - // We add a tiny buffer (1500ms total) for Playwright/Browser overhead. - await expect.poll(async () => { - return await page.evaluate(() => window._liveTestSeams.getAnimCount()); - }, { - message: 'activeAnimations did not drain to 0 within 2x duration', - timeout: 1500, - }).toBe(0); + await step(vp.name + ': the engine goes back to sleep', async () => { + // One rAF tick separates the queue emptying from renderAnimations + // flipping isAnimating off. + const asleep = await page.evaluate(async () => { + for (let i = 0; i < 10 && window._liveTestSeams.isAnimating(); i++) { + await new Promise((r) => requestAnimationFrame(r)); + } + return !window._liveTestSeams.isAnimating(); + }); + assert(asleep, 'isAnimating stayed true after the queue drained'); + }); - // 5. Assert the engine gracefully went back to sleep. - // (#1514 — there is one rAF tick between activeAnimations going to 0 and - // the next renderAnimations frame flipping isAnimating=false. Poll for a - // small jitter window instead of a one-shot read so the test isn't racy - // against that single-frame settling delay.) - await expect.poll(async () => { - return await page.evaluate(() => window._liveTestSeams.isAnimating()); - }, { timeout: 200 }).toBe(false); + await step(vp.name + ': the fading polylines render on the animations pane', async () => { + const n = await page.locator('#liveMap .leaflet-animations-pane > canvas.leaflet-zoom-animated').count(); + assert(n >= 1, 'no Leaflet renderer canvas on .leaflet-animations-pane — the fades went to another pane'); + }); - // 6. Assert recent paths didn't blow past the limit - let recentPathsCount = await page.evaluate(() => window._liveTestSeams.getPathCount()); - expect(recentPathsCount).toBeLessThanOrEqual(5); + await step(vp.name + ': no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + } finally { + await ctx.close(); + } +} - // 7. #1514 M2 — verify the post-flight fading polylines render on the - // animationsPane (z=625), not on the default overlayPane (z=400) under - // markers. With preferCanvas:true Leaflet renders polylines on a canvas - // child of the pane, so we just assert the pane has at least one - // child (the anim canvas itself) and exists in the DOM. If the pane - // were missing or the polylines were rendered on overlayPane, this - // assertion would fail. - const fadePaneChildren = await page.evaluate(() => { - const pane = document.querySelector('.leaflet-pane.leaflet-animations-pane'); - if (!pane) return -1; - return pane.querySelectorAll('svg path, canvas').length; +(async () => { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], }); - expect(fadePaneChildren).toBeGreaterThanOrEqual(1); - }); -}); + } catch (err) { + if (requireChromium) { + console.error('test-pr-1490-live-map-gpu-animations-e2e.js: FAIL — Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-pr-1490-live-map-gpu-animations-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + console.log('=== PR #1490 live map canvas animation engine E2E — ' + BASE + ' ==='); + try { + for (const vp of VIEWPORTS) { + try { await runViewport(browser, vp); } + catch (e) { failed++; console.error(' ✗ ' + vp.name + ': ' + e.message); } + } + } finally { + await browser.close(); + } + console.log('\ntest-pr-1490-live-map-gpu-animations-e2e.js: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})();