diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3f93aa07f..4472efec9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -194,6 +194,7 @@ jobs: node test-issue-1509-detect-preset.js node test-live.js node test-issue-124-rx-coverage-viewport.js + node test-rx-coverage-escape.js node test-issue-125-live-toggles-wiring.js node test-coverage-gate.js node test-node-reach-coverage.js @@ -540,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-all.sh b/test-all.sh index a46a53639..181872c38 100755 --- a/test-all.sh +++ b/test-all.sh @@ -63,6 +63,7 @@ node test-issue-1890-og-url.js node test-traces.js node test-live-multibyte-filter.js node test-issue-124-rx-coverage-viewport.js +node test-rx-coverage-escape.js node test-issue-125-live-toggles-wiring.js node test-issue-2052-touch-target-css.js node test-channel-proposals.js 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); +})(); diff --git a/test-rx-coverage-escape.js b/test-rx-coverage-escape.js index 3556546c3..e3065fa72 100644 --- a/test-rx-coverage-escape.js +++ b/test-rx-coverage-escape.js @@ -1,61 +1,179 @@ +/* test-rx-coverage-escape.js — the RX coverage leaderboard must HTML-escape + * observer names and pubkeys (XSS guard, #14 / #170). + * + * Loads 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, mounts the page, answers /api/rx-leaderboard with hostile + * observers and checks the rendered #rxBoard markup. Nothing from the row + * builder is copied or sliced out of the source, so removing escaping from + * any interpolation in the row builder turns this test red. + */ 'use strict'; -// Unit test for #14: the mobile RX coverage leaderboard must HTML-escape the -// pubkey it interpolates into the row markup (data-rx="..." and the truncated -// fallback label), not only the name. A no-ACL broker / pre-validation rows -// could carry a non-hex pubkey, and the rest of the row is built by string -// concatenation, so an unescaped pubkey is an HTML-injection vector. -// -// Like test-coverage-gate.js we slice the real row-building expression out of -// public/rx-coverage.js and evaluate it in a vm sandbox — no hand-copied -// duplicate — so the test tracks the actual source. -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, 'public', 'rx-coverage.js'), 'utf8'); - -// Slice from `var nm = o.name ...` through the end of the returned row string. -const startMarker = 'var nm = o.name ? escapeHtml(o.name)'; -const endMarker = "o.nodes + '';"; -const startIdx = src.indexOf(startMarker); -assert.ok(startIdx >= 0, 'could not locate row-builder start in rx-coverage.js'); -const endIdx = src.indexOf(endMarker, startIdx); -assert.ok(endIdx >= 0, 'could not locate row-builder end in rx-coverage.js'); -const block = src.slice(startIdx, endIdx + endMarker.length); - -// Canonical escapeHtml (public/app.js). -function escapeHtml(s) { - if (s == null) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +const SRC = fs.readFileSync(__dirname + '/public/rx-coverage.js', 'utf8'); +const APP = fs.readFileSync(__dirname + '/public/app.js', 'utf8'); +const ESC = APP.match(/function escapeHtml\(s\) \{[\s\S]*?\n\}\n/); +assert(ESC, 'could not find escapeHtml in public/app.js'); + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); } } +const flush = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); }; -function renderRow(o) { - const sandbox = { o: o, i: 0, selectedRx: '', escapeHtml: escapeHtml }; +// Mounts the page and renders the leaderboard from `observers`. +// Returns the #rxBoard innerHTML and the sandbox's escapeHtml. +async function renderBoard(observers, hash) { + const pending = []; + const els = {}; + const el = () => ({ innerHTML: '', addEventListener() {}, querySelectorAll: () => [], dataset: {} }); + const layer = () => ({ addTo() { return this; }, clearLayers() {}, bindTooltip() { return this; } }); + const location = { hash: hash || '#/rx-coverage' }; + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + Promise, Math, JSON, Number, String, Array, Object, Date, isFinite, parseFloat, parseInt, URLSearchParams, + window: { MeshConfigReady: Promise.resolve(), MC_CLIENT_RX_COVERAGE: true }, + document: { getElementById: (id) => els[id] || (els[id] = el()), documentElement: {} }, + location, + history: { replaceState: (_s, _t, url) => { location.hash = url; } }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + L: { map: () => ({ setView() { return this; }, on() { return this; }, invalidateSize() {}, remove() {} }), + tileLayer: () => layer(), layerGroup: () => layer(), polygon: () => layer() }, + debounce: (fn) => fn, + setTimeout: () => 0, + clearTimeout: () => {}, + fetch: (url) => new Promise((resolve, reject) => { pending.push({ url, resolve, reject }); }), + }; + sandbox.getHashParams = () => new URLSearchParams((location.hash.split('?')[1] || '')); vm.createContext(sandbox); - return vm.runInContext('(function () { ' + block + ' })()', sandbox); + vm.runInContext(ESC[0], sandbox); + let page; + sandbox.registerPage = (_name, obj) => { page = obj; }; + vm.runInContext(SRC, sandbox); + + page.init({ innerHTML: '' }); + await flush(); + const i = pending.findIndex((p) => /^\/api\/rx-leaderboard\?/.test(p.url)); + assert(i >= 0, 'the page did not request /api/rx-leaderboard'); + pending.splice(i, 1)[0].resolve({ ok: true, json: async () => ({ observers }) }); + await flush(); + return { html: sandbox.document.getElementById('rxBoard').innerHTML, esc: sandbox.escapeHtml }; +} + +// Strictly parses every leaderboard data row: the opening tag must consist of +// exactly the expected double-quoted attributes, with no raw <, > or " inside +// a value, followed by the rank and name spans. Anything an unescaped value +// could inject (an extra attribute, a closed tag, a new element) fails here. +const ROW_ATTRS = ['class', 'data-rx', 'data-name', 'role', 'tabindex', 'aria-pressed', 'aria-label']; +function parseRows(html) { + const rows = []; + let at = 0; + for (;;) { + const start = html.indexOf('
]*)"/y; + for (;;) { + attrRe.lastIndex = pos; + const m = attrRe.exec(html); + if (!m) break; + assert(!(m[1] in attrs), 'duplicate attribute ' + m[1] + ' in ' + html.slice(start, start + 300)); + attrs[m[1]] = m[2]; + pos = attrRe.lastIndex; + } + assert.strictEqual(html[pos], '>', 'row opening tag is malformed (injected markup?): ' + html.slice(start, start + 300)); + at = pos + 1; + if (attrs.class === 'rxb-row rxb-head') continue; + assert.deepStrictEqual(Object.keys(attrs), ROW_ATTRS, 'unexpected row attributes: ' + html.slice(start, pos + 1)); + const body = /^\d+<\/span>([^<>"']*)<\/span>/.exec(html.slice(at)); + assert(body, 'row body is malformed (injected markup in the name?): ' + html.slice(at, at + 200)); + rows.push({ attrs, label: body[1] }); + } + return rows; } +// Every & in the markup must start one of escapeHtml's entities. +function assertNoBareAmp(html) { + const bad = /&(?!(?:amp|lt|gt|quot|#39);)/.exec(html); + assert(!bad, 'raw & in markup: ' + html.slice(Math.max(0, bad && bad.index - 40), (bad && bad.index) + 40)); +} + +const HOSTILE_NAMES = [ + '', + '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); +})();