From fb6bb085a51cbc7ab9312d8b62d9bf249bb83ac5 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 11 Jun 2026 07:34:51 -0700 Subject: [PATCH] fix(analytics): render Channels group-header sprites as HTML, not escaped text (#1657) (#1658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1657 ## Bug On `/analytics` → **Channels** tab, the "Channel Activity" table's group-header rows ("My Channels", "Network", "Encrypted") rendered literal HTML source text: ``` My Channels ``` instead of the actual Phosphor sprites. Per-row encrypted/lock icons rendered fine — the bug was isolated to the group-header render path. ## Root cause `public/analytics.js` `channelTbodyHtml` builds each group-section header by wrapping the section label in `esc()`: ```js esc(sections[si].label) + ' (' + rows.length + ')' ``` But the labels (`sections[].label`) are hardcoded sprite-bearing strings: ```js { key: 'mine', label: ' My Channels' }, ``` `esc()` HTML-encoded the `<` / `>` so the browser displayed the source text rather than rendering the sprite. Affects all 3 groups (and any future group with a sprite). ## Fix Drop the `esc()` wrap on the hardcoded label (single line change, same pattern as M3 commit 4ca73ced for mobile channel avatars). The `()` suffix is numeric and was always safe. ## Tests New `test-issue-1657-analytics-channels-group-sprites-e2e.js` (mobile 375 viewport, matching the bug report): - (1) at least one group-header row renders - (2) every header row contains a real `` child - (3) per-group sprite refs resolve (My Channels → `#ph-key`, Network → `#ph-radio`, Encrypted → `#ph-lock`) - (4) the Channel Activity table's `innerText` contains no literal ` --- .github/workflows/deploy.yml | 1 + public/analytics.js | 6 +- ...57-analytics-channels-group-sprites-e2e.js | 148 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 test-issue-1657-analytics-channels-group-sprites-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 272751a5..e9e3db2d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -403,6 +403,7 @@ jobs: 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 CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1648-m4-icons-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1657-analytics-channels-group-sprites-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/analytics.js b/public/analytics.js index 6769da3a..6ee8487a 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1033,7 +1033,11 @@ if (!rows.length) continue; parts.push( '' + - esc(sections[si].label) + ' (' + rows.length + ')' + + // sections[].label is a hardcoded sprite-bearing string (no user + // input) — must be inserted raw so the renders. esc() here + // HTML-escaped the angle brackets and surfaced literal "" + // text in the table (#1657). + sections[si].label + ' (' + rows.length + ')' + '' ); for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri])); diff --git a/test-issue-1657-analytics-channels-group-sprites-e2e.js b/test-issue-1657-analytics-channels-group-sprites-e2e.js new file mode 100644 index 00000000..f60cef38 --- /dev/null +++ b/test-issue-1657-analytics-channels-group-sprites-e2e.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/* Issue #1657 — Analytics → Channels: group-header rows must render real + * Phosphor sprites (My Channels / Network / Encrypted), NOT the + * HTML-escaped literal "" source text. + * + * Bug: public/analytics.js passed the hardcoded group-section label (which + * contains the sprite markup) through esc(), HTML-encoding the angle + * brackets so the browser displayed the source instead of rendering it. + * + * This test asserts (in real Chromium against a running server): + * (1) The Channel Activity table has at least one group-header row. + * (2) Each rendered group-header row contains a real + * element (NOT escaped text). + * (3) For each expected group (key/radio/lock), if the label text is + * present, an actual resolves + * inside the same row. + * (4) The Channel Activity table's innerText contains zero literal + * " { + const tb = document.getElementById('channelsTbody'); + return tb && tb.querySelectorAll('tr').length > 0; + }, null, { timeout: 12000 }); + } catch { + fail('channelsTbody never populated within 12s'); + await browser.close(); + console.log(`\ntest-issue-1657: ${passes} passed, ${failures} failed`); + process.exit(failures ? 1 : 0); + } + + // Allow grouped render (decorate + buildHashKeyMap promise) to settle. + await page.waitForTimeout(800); + + const result = await page.evaluate(() => { + const tb = document.getElementById('channelsTbody'); + if (!tb) return { error: 'tbody missing' }; + const headerRows = Array.from(tb.querySelectorAll('tr.ch-section-row')); + const perHeader = headerRows.map((tr) => { + const cell = tr.querySelector('td.ch-section-header') || tr.querySelector('td'); + const text = (cell && cell.textContent) || ''; + const svgs = cell ? cell.querySelectorAll('svg.ph-icon').length : 0; + const uses = cell + ? Array.from(cell.querySelectorAll('svg.ph-icon use')) + .map((u) => (u.getAttribute('href') || u.getAttribute('xlink:href') || '')) + : []; + return { text: text.trim(), svgs, uses }; + }); + const tableEl = document.getElementById('channelsTable') || tb.parentElement; + const innerHTML = tableEl ? tableEl.innerHTML : ''; + // innerText reflects what the user sees; literal "`); + + // (3) per expected group, verify the sprite ref + const expected = [ + { match: /My Channels/i, ref: /#ph-key$/, name: 'My Channels → #ph-key' }, + { match: /Network/i, ref: /#ph-radio$/, name: 'Network → #ph-radio' }, + { match: /Encrypted/i, ref: /#ph-lock$/, name: 'Encrypted → #ph-lock' }, + ]; + for (const e of expected) { + const row = result.headerRows.find((h) => e.match.test(h.text)); + if (!row) { + // Not all groups are guaranteed to have rows (depends on fixture data). + // Skip silently — group only renders if it has channels. + continue; + } + const ok = row.uses.some((u) => e.ref.test(u)); + if (!ok) fail(`(3) ${e.name}: expected ${e.ref} but uses=${JSON.stringify(row.uses)}`); + else pass(`(3) ${e.name} rendered as real sprite`); + } + + // (4) no literal " { + console.error('test-issue-1657: FAIL —', err); + process.exit(1); +});