From cacc8bce5a8121aa3459210f77339b92ddc3e61f Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 14:48:57 +0000 Subject: [PATCH 01/15] test(channel-colors): expect the 3px border-only row style; register (#189) 68a4628e (#675) intentionally changed getRowStyle to a 3px left border with no background tint (comment in channel-colors.js cites #674). The test still expected the old 4px border + 10% tint. Assert the exact string for GRP_TXT and the CHAN alias. Mutant: 3px -> 4px in channel-colors.js turns both cases red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- test-all.sh | 3 +++ test-channel-colors.js | 9 ++++----- test-test-all.js | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test-all.sh b/test-all.sh index 1eb9ecff4..d3543cec1 100755 --- a/test-all.sh +++ b/test-all.sh @@ -237,6 +237,9 @@ run test-map-clustering.js run test-panel-corner.js run test-customizer-v2.js +# Repaired orphans (#189) +run test-channel-colors.js + # test-all.sh self-test (#174) run test-test-all.js diff --git a/test-channel-colors.js b/test-channel-colors.js index 06c0e249d..9fb35962b 100644 --- a/test-channel-colors.js +++ b/test-channel-colors.js @@ -146,20 +146,19 @@ test('getRowStyle returns empty string for null channel', function() { assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', null), ''); }); -test('getRowStyle returns border + background for assigned GRP_TXT channel', function() { +// #675 (#674): a 3px left border only, no background tint. +test('getRowStyle returns a 3px left border for assigned GRP_TXT channel', function() { const ctx = makeSandbox(); ctx.window.ChannelColors.set('#sf', '#ef4444'); const style = ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#sf'); - assert.ok(style.includes('border-left:4px solid #ef4444'), 'should have left border'); - assert.ok(style.includes('background:#ef44441a'), 'should have 10% opacity background'); + assert.strictEqual(style, 'border-left:3px solid #ef4444;'); }); test('getRowStyle works with CHAN type (alias for GRP_TXT)', function() { const ctx = makeSandbox(); ctx.window.ChannelColors.set('#mesh', '#3b82f6'); const style = ctx.window.ChannelColors.getRowStyle('CHAN', '#mesh'); - assert.ok(style.includes('border-left:4px solid #3b82f6'), 'should have left border'); - assert.ok(style.includes('background:#3b82f61a'), 'should have background tint'); + assert.strictEqual(style, 'border-left:3px solid #3b82f6;'); }); test('getRowStyle returns empty when channel has no assigned color', function() { diff --git a/test-test-all.js b/test-test-all.js index f613e4961..84e8292f1 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -130,7 +130,6 @@ test('test-all.sh registers itself through this test', () => { // test past the check below: register it instead. const KNOWN_UNREGISTERED = { // red unit tests (stale after intended UI changes) - 'test-channel-colors.js': 'red: expects 4px border + tint, #675 changed it to 3px', 'test-channel-ux-followup.js': 'red: copy text changed by the Phosphor migration', 'test-channel-ux-round2.js': 'red: expects the ๐Ÿ“ค glyph, now #ph-share-network', 'test-drag-manager.js': 'red: removeAttribute mock does not update dataset (#1567)', From 91c9bcfa61b2eae86506074d37f612582e03b87f Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 14:49:29 +0000 Subject: [PATCH 02/15] test(channels): follow the #1648 M3 sprite copy in two UX tests; register (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b812a98a (#1648 M3) replaced the โœ• and ๐Ÿ“ค glyphs with Phosphor sprites: the privacy hint now says "Use the close button to remove individual channels", and the share button glyph is the #ph-share-network sprite + " Share". Both were deliberate; the tests still asserted the emoji. - test-channel-ux-followup.js: assert the new copy, and that the remove button it refers to renders the #ph-x close icon. - test-channel-ux-round2.js: assert the share glyph argument is the aria-hidden #ph-share-network sprite + " Share". Mutants (channels.js): hint reworded; remove glyph #ph-x -> #ph-trash; " Share" dropped; icon dropped. Each turns its test red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- test-all.sh | 2 ++ test-channel-ux-followup.js | 8 ++++++-- test-channel-ux-round2.js | 13 +++++++------ test-test-all.js | 2 -- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/test-all.sh b/test-all.sh index d3543cec1..7eeb6c661 100755 --- a/test-all.sh +++ b/test-all.sh @@ -239,6 +239,8 @@ run test-customizer-v2.js # Repaired orphans (#189) run test-channel-colors.js +run test-channel-ux-followup.js +run test-channel-ux-round2.js # test-all.sh self-test (#174) run test-test-all.js diff --git a/test-channel-ux-followup.js b/test-channel-ux-followup.js index 6e2f284a2..c049b5207 100644 --- a/test-channel-ux-followup.js +++ b/test-channel-ux-followup.js @@ -43,8 +43,12 @@ assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*packets/.test(chSrc), console.log('\n=== Fix 3: privacy footer wording ==='); assert(!/Clear browser data to remove stored keys/.test(chSrc), 'old "Clear browser data to remove stored keys" copy is gone'); -assert(/Use\s+โœ•\s+to remove individual channels/.test(chSrc), - 'new copy points at the โœ• button for individual key removal'); +// b812a98a (#1648 M3) swapped the โœ• glyph for the #ph-x sprite and reworded +// the hint to "close button"; the remove button must still be that X icon. +assert(/Use the close button to remove individual channels/.test(chSrc), + 'new copy points at the close (X) button for individual key removal'); +assert(/iconBtn\(\s*'ch-remove-btn'[^)]*'' + name + ''); + assert.ok(idx >= 0, name + ' row missing'); + return html.slice(idx, html.indexOf('', idx)); +} +const at = (sec) => new Date(Date.UTC(2026, 0, 1, 0, 0, sec)).toISOString(); + +await test('Write Sources: a source at >10ร— its rolling baseline gets the warning flag, a steady one does not', async () => { const sb = loadPerf(); - // Reset any previous cached snapshot - sb.ctx.window._perfWriteSourcesPrev = null; - const t0 = '2026-01-01T00:00:00Z'; - const t1 = '2026-01-01T00:00:01Z'; // 1s later - const phase1 = { sources: { tx_inserted: 100, backfill_path_json: 0 }, sampleAt: t0 }; - const phase2 = { sources: { tx_inserted: 105, backfill_path_json: 1000 }, sampleAt: t1 }; - // First render: no prev โ†’ no flags possible - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase1); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); - // Second render: simulate tick with delta. Reuse the same fetch wiring. - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase2); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); - const html = sb.getHtml(); - // After phase2: tx_rate = 5/s, backfill_rate = 1000/s โ†’ ratio = 200x โ†’ โš ๏ธ - const idx = html.indexOf('backfill_path_json'); - assert.ok(idx >= 0, 'backfill_path_json row missing'); - const row = html.slice(idx, idx + 400); - assert.ok(row.includes('โš ๏ธ'), 'expected โš ๏ธ on backfill row when rate ratio >10ร—, row=' + row); + // 40 s of baseline (backfill 1/s, tx 5/s), then 1 s where backfill jumps + // to 100/s while tx stays at 5/s. + const html = await renderWriteSources(sb, [ + { sources: { tx_inserted: 0, backfill_path_json: 0 }, sampleAt: at(0) }, + { sources: { tx_inserted: 200, backfill_path_json: 40 }, sampleAt: at(40) }, + { sources: { tx_inserted: 205, backfill_path_json: 140 }, sampleAt: at(41) }, + ]); + const backfill = sourceRow(html, 'backfill_path_json'); + assert.ok(backfill.includes('100.001.00'), 'backfill rate 100/s vs baseline 1/s rendered, row=' + backfill); + assert.ok(backfill.includes(WARN), 'expected the warning icon on the backfill row, row=' + backfill); + const tx = sourceRow(html, 'tx_inserted'); + assert.ok(tx.includes('5.005.00'), 'tx rate and baseline 5/s rendered, row=' + tx); + assert.ok(!tx.includes(WARN), 'expected NO warning icon on the steady tx_inserted row, row=' + tx); }); -await test('Backfill anomaly: tx_inserted <100 baseline guard SUPPRESSES โš ๏ธ', async () => { - // Same shape but tx_inserted stays well below the 100 floor; even a huge - // backfill rate ratio must NOT fire while we lack a meaningful baseline. +await test('Write Sources: less than 30 s of history SUPPRESSES the warning flag', async () => { const sb = loadPerf(); - sb.ctx.window._perfWriteSourcesPrev = null; - const t0 = '2026-01-01T00:00:00Z'; - const t1 = '2026-01-01T00:00:01Z'; - const phase1 = { sources: { tx_inserted: 5, backfill_path_json: 0 }, sampleAt: t0 }; - const phase2 = { sources: { tx_inserted: 6, backfill_path_json: 1000 }, sampleAt: t1 }; - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase1); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase2); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); - const html = sb.getHtml(); - const idx = html.indexOf('backfill_path_json'); - const row = html.slice(idx, idx + 400); - assert.ok(!row.includes('โš ๏ธ'), 'expected NO โš ๏ธ on backfill row when tx_inserted<100, row=' + row); + // Same spike, but the baseline only spans 10 s (< minHistorySec 30). + const html = await renderWriteSources(sb, [ + { sources: { tx_inserted: 0, backfill_path_json: 0 }, sampleAt: at(0) }, + { sources: { tx_inserted: 50, backfill_path_json: 10 }, sampleAt: at(10) }, + { sources: { tx_inserted: 55, backfill_path_json: 110 }, sampleAt: at(11) }, + ]); + const backfill = sourceRow(html, 'backfill_path_json'); + assert.ok(backfill.includes('100.00โ€”'), 'rate shown, baseline not yet established, row=' + backfill); + assert.ok(!backfill.includes(WARN), 'expected NO warning icon with < 30 s of history, row=' + backfill); }); console.log(`\n${passed} passed, ${failed} failed\n`); diff --git a/test-test-all.js b/test-test-all.js index 12218f61f..7d4b725c9 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -131,7 +131,6 @@ test('test-all.sh registers itself through this test', () => { const KNOWN_UNREGISTERED = { // red unit tests (stale after intended UI changes) 'test-packets.js': 'red on 1 assertion: collapsed group caret is #ph-caret-up, a real bug (#189); register once packets.js is fixed', - 'test-perf-disk-io-1120.js': 'red: โš ๏ธ is #ph-warning; anomaly detector reworked (#1593)', // need something CI's unit job does not have 'test-marker-outline-weight.js': 'needs @playwright/test (not a dependency) and a server', 'test-table-sort.js': 'needs jsdom (not a dependency)', From ffbeb550b5cfaab93f9eba170e8e40f45333c754 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 14:55:20 +0000 Subject: [PATCH 09/15] test(table-sort): port test-table-sort.js from jsdom to vm; register (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdom was never a dependency, so this test could not run anywhere and public/table-sort.js had no unit test (only the observers-sort E2E). The file now loads table-sort.js with vm and a small DOM shim defined in the test: attributes, classList, style, child lists with real moves on appendChild, remove(), listeners + click(), cells, textContent, and a selector engine for the few selectors table-sort.js uses. All 22 cases are kept. The two arrow cases expected โ–ฒ/โ–ผ; #1648 M2 switched the arrow to the #ph-caret-up / #ph-caret-down sprite, so they check that now. Mutants (table-sort.js): direction ignored; up/down carets swapped; destroy() keeps aria-sort; rows not re-appended; custom comparators ignored; NaN sorts first. Each turns the test red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- test-all.sh | 1 + test-table-sort.js | 210 +++++++++++++++++++++++++++++++-------------- test-test-all.js | 1 - 3 files changed, 146 insertions(+), 66 deletions(-) diff --git a/test-all.sh b/test-all.sh index 3890960f4..edea7f19a 100755 --- a/test-all.sh +++ b/test-all.sh @@ -247,6 +247,7 @@ run test-hop-resolver-affinity.js run test-issue-1470-card-bg-contrast.js run test-issue-1646-compare-polish.js run test-perf-disk-io-1120.js +run test-table-sort.js # test-all.sh self-test (#174) run test-test-all.js diff --git a/test-table-sort.js b/test-table-sort.js index a6255b911..72da63a0b 100644 --- a/test-table-sort.js +++ b/test-table-sort.js @@ -1,7 +1,7 @@ /* test-table-sort.js โ€” Unit tests for TableSort utility */ 'use strict'; -const { JSDOM } = require('jsdom'); +const vm = require('vm'); const fs = require('fs'); const path = require('path'); const assert = require('assert'); @@ -21,52 +21,132 @@ function test(name, fn) { } } -function createDOM(html) { - const dom = new JSDOM(`${html}`, { - url: 'http://localhost', - runScripts: 'dangerously' - }); - // Load TableSort into this DOM - const script = fs.readFileSync(path.join(__dirname, 'public', 'table-sort.js'), 'utf8'); - const el = dom.window.document.createElement('script'); - el.textContent = script; - dom.window.document.head.appendChild(el); - return dom; +// โ”€โ”€ Minimal DOM (#189: jsdom is not a dependency) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Just enough of the element API that table-sort.js touches: attributes, +// classList, style, child lists with real moves on appendChild, remove(), +// listeners + click(), cells, textContent and a small selector engine +// (tag, .class, tag[attr], tag[attr="v"]; descendants, document order). +class FakeEl { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentNode = null; + this.attrs = {}; + this.style = {}; + this.listeners = {}; + this.text = ''; + const el = this; + this.classList = { + add(c) { const s = el._classes(); if (!s.includes(c)) el.className = s.concat(c).join(' '); }, + remove(c) { el.className = el._classes().filter((x) => x !== c).join(' '); }, + contains(c) { return el._classes().includes(c); }, + }; + } + _classes() { return (this.attrs.class || '').split(/\s+/).filter(Boolean); } + get className() { return this.attrs.class || ''; } + set className(v) { this.attrs.class = String(v); } + getAttribute(k) { return Object.prototype.hasOwnProperty.call(this.attrs, k) ? this.attrs[k] : null; } + setAttribute(k, v) { this.attrs[k] = String(v); } + removeAttribute(k) { delete this.attrs[k]; } + appendChild(child) { + if (child.parentNode) child.remove(); + child.parentNode = this; + this.children.push(child); + return child; + } + remove() { + if (!this.parentNode) return; + const sibs = this.parentNode.children; + sibs.splice(sibs.indexOf(this), 1); + this.parentNode = null; + } + // innerHTML is only ever assigned (the sort arrow); keep the markup as text. + set innerHTML(html) { this.children = []; this.text = String(html); } + get innerHTML() { return this.text + this.children.map((c) => c.innerHTML).join(''); } + get textContent() { return this.text + this.children.map((c) => c.textContent).join(''); } + get cells() { return this.children.filter((c) => c.tagName === 'TD' || c.tagName === 'TH'); } + addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); } + removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter((f) => f !== fn); } + click() { (this.listeners.click || []).slice().forEach((fn) => fn({ type: 'click', preventDefault() {} })); } + _descendants() { return this.children.flatMap((c) => [c, ...c._descendants()]); } + querySelectorAll(sel) { return this._descendants().filter((el) => matches(el, sel)); } + querySelector(sel) { return this.querySelectorAll(sel)[0] || null; } +} + +function matches(el, sel) { + const m = /^([a-z]*)(?:\.([\w-]+))?(?:\[([\w-]+)(?:="([^"]*)")?\])?$/.exec(sel); + if (!m) throw new Error('FakeEl: unsupported selector ' + sel); + const [, tag, cls, attr, val] = m; + if (tag && el.tagName !== tag.toUpperCase()) return false; + if (cls && !el.classList.contains(cls)) return false; + if (attr && el.getAttribute(attr) == null) return false; + if (val !== undefined && el.getAttribute(attr) !== val) return false; + return true; +} + +function createDOM(table) { + const body = new FakeEl('body'); + if (table) body.appendChild(table); + const store = {}; + const document = { + body, + createElement: (tag) => new FakeEl(tag), + getElementById: (id) => body._descendants().find((el) => el.getAttribute('id') === id) || null, + querySelector: (sel) => body.querySelector(sel), + querySelectorAll: (sel) => body.querySelectorAll(sel), + }; + const window = { document }; + const ctx = { + window, document, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + }, + }; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(__dirname, 'public', 'table-sort.js'), 'utf8'), ctx); + return { window }; } function makeTable(headers, rows) { // headers: [{key, type?, label}], rows: [[value, ...]] - let html = ''; + const el = (tag, attrs, text) => { + const e = new FakeEl(tag); + for (const k in attrs || {}) e.setAttribute(k, attrs[k]); + if (text != null) e.text = String(text); + return e; + }; + const table = el('table', { id: 't' }); + const thead = table.appendChild(el('thead')); + const headRow = thead.appendChild(el('tr')); for (const h of headers) { - html += ``; + headRow.appendChild(el('th', Object.assign({ 'data-sort-key': h.key }, h.type ? { 'data-type': h.type } : {}), h.label || h.key)); } - html += ''; + const tbody = table.appendChild(el('tbody')); for (const row of rows) { - html += ''; - for (let i = 0; i < row.length; i++) { - const val = row[i]; - if (typeof val === 'object' && val !== null) { - html += ``; - } else { - html += ``; - } + const tr = tbody.appendChild(el('tr')); + for (const val of row) { + if (typeof val === 'object' && val !== null) tr.appendChild(el('td', { 'data-value': val.dataValue }, val.text || '')); + else tr.appendChild(el('td', { 'data-value': val }, val)); } - html += ''; } - html += '
${h.label || h.key}
${val.text || ''}${val}
'; - return html; + return table; } function getColumnValues(dom, colIndex) { - const rows = dom.window.document.querySelectorAll('tbody tr'); - return Array.from(rows).map(r => r.cells[colIndex].getAttribute('data-value')); + const rows = dom.window.document.querySelector('tbody').children; + return rows.map(r => r.cells[colIndex].getAttribute('data-value')); } +// The arrow table-sort.js renders: since #1648 M2 a Phosphor caret sprite +// (was โ–ฒ / โ–ผ). +const caret = (dir) => '#ph-caret-' + dir + '"'; + console.log('\nTableSort โ€” comparators'); test('text comparator: basic alphabetical', () => { const cmp = (() => { - const dom = createDOM('
'); + const dom = createDOM(null); return dom.window.TableSort.comparators.text; })(); assert.ok(cmp('apple', 'banana') < 0); @@ -75,14 +155,14 @@ test('text comparator: basic alphabetical', () => { }); test('text comparator: null/undefined handling', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.text; assert.strictEqual(cmp(null, null), 0); assert.strictEqual(cmp(undefined, undefined), 0); }); test('numeric comparator: basic numbers', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.numeric; assert.ok(cmp('1', '2') < 0); assert.ok(cmp('10', '2') > 0); @@ -90,7 +170,7 @@ test('numeric comparator: basic numbers', () => { }); test('numeric comparator: NaN sorts last', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.numeric; assert.ok(cmp('abc', '5') > 0); // NaN > number (sorts last) assert.ok(cmp('5', 'abc') < 0); @@ -98,14 +178,14 @@ test('numeric comparator: NaN sorts last', () => { }); test('numeric comparator: negative numbers', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.numeric; assert.ok(cmp('-10', '-5') < 0); assert.ok(cmp('-5', '-10') > 0); }); test('date comparator: ISO dates', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.date; assert.ok(cmp('2024-01-01T00:00:00Z', '2024-06-01T00:00:00Z') < 0); assert.ok(cmp('2024-06-01T00:00:00Z', '2024-01-01T00:00:00Z') > 0); @@ -113,14 +193,14 @@ test('date comparator: ISO dates', () => { }); test('date comparator: invalid dates sort last', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.date; assert.ok(cmp('invalid', '2024-01-01') > 0); assert.ok(cmp('2024-01-01', 'invalid') < 0); }); test('dBm comparator: strips suffix', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.dbm; assert.ok(cmp('-120 dBm', '-80 dBm') < 0); assert.ok(cmp('-80 dBm', '-120 dBm') > 0); @@ -128,7 +208,7 @@ test('dBm comparator: strips suffix', () => { }); test('dBm comparator: works without suffix', () => { - const dom = createDOM('
'); + const dom = createDOM(null); const cmp = dom.window.TableSort.comparators.dbm; assert.ok(cmp('-120', '-80') < 0); }); @@ -136,11 +216,11 @@ test('dBm comparator: works without suffix', () => { console.log('\nTableSort โ€” DOM sorting'); test('sort ascending by text column', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'name'}], [['Charlie'], ['Alice'], ['Bob']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' }); const vals = getColumnValues(dom, 0); @@ -148,11 +228,11 @@ test('sort ascending by text column', () => { }); test('sort descending by numeric column', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'val', type: 'numeric'}], [['3'], ['1'], ['2']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'val', defaultDirection: 'desc' }); const vals = getColumnValues(dom, 0); @@ -160,11 +240,11 @@ test('sort descending by numeric column', () => { }); test('click toggles direction', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'name'}], [['B'], ['A'], ['C']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); const inst = dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc' }); @@ -184,11 +264,11 @@ test('click toggles direction', () => { console.log('\nTableSort โ€” aria-sort attributes'); test('aria-sort set correctly on active column', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}, {key: 'b'}], [['1', 'x'], ['2', 'y']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); @@ -199,11 +279,11 @@ test('aria-sort set correctly on active column', () => { }); test('aria-sort updates on direction change', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}], [['1'], ['2']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); @@ -215,11 +295,11 @@ test('aria-sort updates on direction change', () => { }); test('aria-sort updates when switching columns', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}, {key: 'b'}], [['1', 'x'], ['2', 'y']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); @@ -234,42 +314,42 @@ test('aria-sort updates when switching columns', () => { console.log('\nTableSort โ€” visual indicator'); test('sort arrow shows on active column', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}], [['1'], ['2']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); const arrow = dom.window.document.querySelector('.sort-arrow'); assert.ok(arrow, 'sort arrow should exist'); - assert.ok(arrow.textContent.includes('โ–ฒ'), 'ascending should show โ–ฒ'); + assert.ok(arrow.innerHTML.includes(caret('up')), 'ascending should show the up caret'); }); test('sort arrow changes on direction toggle', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}], [['1'], ['2']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); const th = dom.window.document.querySelector('th[data-sort-key="a"]'); th.click(); // desc const arrow = dom.window.document.querySelector('.sort-arrow'); - assert.ok(arrow.textContent.includes('โ–ผ'), 'descending should show โ–ผ'); + assert.ok(arrow.innerHTML.includes(caret('down')), 'descending should show the down caret'); }); console.log('\nTableSort โ€” onSort callback'); test('onSort fires with column and direction', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}, {key: 'b'}], [['1', 'x'], ['2', 'y']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); let called = null; dom.window.TableSort.init(table, { @@ -287,11 +367,11 @@ test('onSort fires with column and direction', () => { console.log('\nTableSort โ€” domReorder: false'); test('domReorder: false skips DOM sorting', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'name'}], [['C'], ['A'], ['B']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'name', defaultDirection: 'asc', domReorder: false }); @@ -303,11 +383,11 @@ test('domReorder: false skips DOM sorting', () => { console.log('\nTableSort โ€” destroy'); test('destroy removes event handlers and cleans up', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'a'}], [['2'], ['1']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); const inst = dom.window.TableSort.init(table, { defaultColumn: 'a', defaultDirection: 'asc' }); @@ -322,11 +402,11 @@ test('destroy removes event handlers and cleans up', () => { console.log('\nTableSort โ€” custom comparators'); test('custom comparator overrides built-in', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'val', type: 'numeric'}], [['3'], ['1'], ['2']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); // Custom: reverse numeric dom.window.TableSort.init(table, { @@ -340,11 +420,11 @@ test('custom comparator overrides built-in', () => { console.log('\nTableSort โ€” date sort with data-type="date"'); test('date column sorts correctly', () => { - const html = makeTable( + const tableEl = makeTable( [{key: 'ts', type: 'date'}], [['2024-06-15T10:00:00Z'], ['2024-01-01T00:00:00Z'], ['2024-12-25T23:59:59Z']] ); - const dom = createDOM(html); + const dom = createDOM(tableEl); const table = dom.window.document.getElementById('t'); dom.window.TableSort.init(table, { defaultColumn: 'ts', defaultDirection: 'asc' }); const vals = getColumnValues(dom, 0); diff --git a/test-test-all.js b/test-test-all.js index 7d4b725c9..136e53ba4 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -133,7 +133,6 @@ const KNOWN_UNREGISTERED = { 'test-packets.js': 'red on 1 assertion: collapsed group caret is #ph-caret-up, a real bug (#189); register once packets.js is fixed', // need something CI's unit job does not have 'test-marker-outline-weight.js': 'needs @playwright/test (not a dependency) and a server', - 'test-table-sort.js': 'needs jsdom (not a dependency)', 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E 'test-channel-modal-e2e.js': 'red: Add button text and sidebar sections changed', From b25d177f3ebf670e87a878c879adbabab842d874 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 14:57:52 +0000 Subject: [PATCH 10/15] test(live): port the pulse-ring weight spec to plain playwright; register in CI (#189) test-marker-outline-weight.js was an @playwright/test ESM spec, and that runner is not a dependency, so it never ran. #1521 rewrote it to check that the canvas pulse highlight ring keeps a >= 2px stroke while visible; nothing else covers that (test-live-dt-cap-1524.js only checks stepPulse's dt cap). Renamed to test-live-pulse-ring-weight-e2e.js and ported to the plain playwright pattern of test-pr-1490-live-map-gpu-animations-e2e.js (CHROMIUM_REQUIRE / SKIP, rAF awaited in the page). It follows the one pulse it queues until the engine drops it, and requires frames in both the 3px (hl_op > 0.4) and the 2px phase. The old spec also passed when no pulse was queued (min over no frames = Infinity >= 2); the port fails then. Registered in the Playwright step of deploy.yml against the fixture server. Fork guards unchanged (9). Mutants (live.js, served from a copy): thin phase 2 -> 1px; initial weight 3 -> 1; pulseNode returns before queueing. Each turns it red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- .github/workflows/deploy.yml | 2 + test-live-pulse-ring-weight-e2e.js | 124 +++++++++++++++++++++++++++++ test-marker-outline-weight.js | 51 ------------ test-test-all.js | 1 - 4 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 test-live-pulse-ring-weight-e2e.js delete mode 100644 test-marker-outline-weight.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 495d06c5f..43c73125e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -526,6 +526,8 @@ jobs: CHROMIUM_REQUIRE=1 node test-a11y-1719-contrast-root-causes-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-nodes-favorite-rerender-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-packet-trace-alignment-e2e.js 2>&1 | tee -a e2e-output.txt + # Root E2Es repaired or ported to plain playwright in #189. + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-pulse-ring-weight-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/test-live-pulse-ring-weight-e2e.js b/test-live-pulse-ring-weight-e2e.js new file mode 100644 index 000000000..8a0e35e5c --- /dev/null +++ b/test-live-pulse-ring-weight-e2e.js @@ -0,0 +1,124 @@ +/** + * E2E (#1521, ported for #189): the Live map's node-pulse highlight ring + * never drops below a 2px stroke while it is visible (a11y minimum). + * + * Since #1521 the pulse rings are drawn on the canvas animation engine: + * pulseNode() queues a pulse with hl_weight 3, and stepPulse() thins it to 2 + * once hl_op falls to 0.4 or below, until the ring fades out (hl_op <= 0). + * + * The test loads /#/live, waits for init and an idle engine in LIVE mode, + * fires one pulse through window._liveTestSeams.triggerPulse, and then + * follows that pulse frame by frame (awaiting rAF inside the page, as in + * test-pr-1490-live-map-gpu-animations-e2e.js) until the engine drops it. + * It checks that the ring was seen visible in both phases (so the thin + * phase was really observed) and that its weight was >= 2 in every visible + * frame. + * + * Previously test-marker-outline-weight.js, an @playwright/test spec; + * that runner is not a dependency, so the spec never ran. This port uses the + * plain `playwright` API like the other *-e2e.js files. + * + * Usage: BASE_URL=http://localhost:13581 node test-live-pulse-ring-weight-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const MIN_WEIGHT = 2; +// The ring fades out in ~0.7s at 1x; 5s is ample headroom for slow CI. +const LIFETIME_TIMEOUT_MS = 5000; + +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'); } + +async function run(browser) { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + 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._liveTestSeams.triggerPulse === 'function'); + await page.evaluate(() => { if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE'); }); + + const seen = await page.evaluate(async (timeoutMs) => { + const seams = window._liveTestSeams; + // stepPulse runs for every queued pulse, on screen or not. + const before = seams.getPulses().length; + seams.triggerPulse('test-189-pulse-ring', [37.45, -122.05], 'ADVERT'); + const pulses = seams.getPulses(); + if (pulses.length !== before + 1) return { queued: false }; + const pulse = pulses[pulses.length - 1]; + const out = { queued: true, initialWeight: pulse.hl_weight, frames: 0, thick: 0, thin: 0, + minWeight: Infinity, minWeightOp: null, done: false }; + const t0 = performance.now(); + while (performance.now() - t0 < timeoutMs) { + await new Promise((r) => requestAnimationFrame(r)); + if (!seams.getPulses().includes(pulse)) { out.done = true; break; } + if (!(pulse.hl_op > 0)) continue; + out.frames++; + if (pulse.hl_op > 0.4) out.thick++; else out.thin++; + if (pulse.hl_weight < out.minWeight) { out.minWeight = pulse.hl_weight; out.minWeightOp = pulse.hl_op; } + } + out.ms = Math.round(performance.now() - t0); + return out; + }, LIFETIME_TIMEOUT_MS); + + await step('the pulse is queued with a ' + seen.initialWeight + 'px ring', async () => { + assert(seen.queued, 'triggerPulse did not add a pulse to activePulses'); + assert(seen.initialWeight >= MIN_WEIGHT, 'initial hl_weight ' + seen.initialWeight + ' < ' + MIN_WEIGHT); + }); + + await step('the ring is followed through its visible life (' + seen.frames + ' frames, ' + + seen.thick + ' thick + ' + seen.thin + ' thin, ' + seen.ms + 'ms)', async () => { + assert(seen.done, 'the pulse was not removed within ' + LIFETIME_TIMEOUT_MS + 'ms'); + assert(seen.thick > 0, 'no visible frame with hl_op > 0.4 was observed'); + assert(seen.thin > 0, 'no visible frame with 0 < hl_op <= 0.4 was observed (the thin phase)'); + }); + + await step('the visible ring is never thinner than ' + MIN_WEIGHT + 'px (min ' + seen.minWeight + ')', async () => { + assert(seen.minWeight >= MIN_WEIGHT, 'hl_weight dropped to ' + seen.minWeight + ' at hl_op ' + seen.minWeightOp); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + } finally { + await ctx.close(); + } +} + +(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'], + }); + } catch (err) { + if (requireChromium) { + console.error('test-live-pulse-ring-weight-e2e.js: FAIL โ€” Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-live-pulse-ring-weight-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + console.log('=== #1521 live pulse ring weight E2E โ€” ' + BASE + ' ==='); + try { + try { await run(browser); } + catch (e) { failed++; console.error(' โœ— ' + e.message); } + } finally { + await browser.close(); + } + console.log('\ntest-live-pulse-ring-weight-e2e.js: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})(); diff --git a/test-marker-outline-weight.js b/test-marker-outline-weight.js deleted file mode 100644 index 098858958..000000000 --- a/test-marker-outline-weight.js +++ /dev/null @@ -1,51 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Canvas Animations A11y', () => { - test('Canvas pulse highlight ring maintains minimum a11y weight of 2', async ({ page }) => { - // Navigate to the live map - await page.goto('/#/live'); - - // Wait for the map and our test seams to initialize - await page.waitForFunction(() => window._liveTestSeams !== undefined); - - // Evaluate the pulse lifecycle inside the browser context - const minWeightSeen = await page.evaluate(async () => { - return new Promise((resolve) => { - const seams = window._liveTestSeams; - - // Trigger a synthetic pulse at an arbitrary coordinate - seams.triggerPulse('a11y-test-node', [38.8951, -77.0364], 'ADVERT'); - - let minSeenWeight = Infinity; - - // Monitor the pulse frame-by-frame - function checkFrame() { - const pulses = seams.getPulses(); - - // If the array is empty, the pulse finished its lifecycle - if (pulses.length === 0) { - resolve(minSeenWeight); - return; - } - - const p = pulses[0]; - - // Only assert weight while the highlight ring is actually visible - if (p.hl_op > 0) { - if (p.hl_weight < minSeenWeight) { - minSeenWeight = p.hl_weight; - } - } - - requestAnimationFrame(checkFrame); - } - - // Start the monitoring loop - requestAnimationFrame(checkFrame); - }); - }); - - // Assert that at no point during the visible lifecycle did the stroke weight drop below 2 - expect(minWeightSeen).toBeGreaterThanOrEqual(2); - }); -}); \ No newline at end of file diff --git a/test-test-all.js b/test-test-all.js index 136e53ba4..932f9d12c 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -132,7 +132,6 @@ const KNOWN_UNREGISTERED = { // red unit tests (stale after intended UI changes) 'test-packets.js': 'red on 1 assertion: collapsed group caret is #ph-caret-up, a real bug (#189); register once packets.js is fixed', // need something CI's unit job does not have - 'test-marker-outline-weight.js': 'needs @playwright/test (not a dependency) and a server', 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E 'test-channel-modal-e2e.js': 'red: Add button text and sidebar sections changed', From 4b7d7a81a198b9d7d0b8b44318f7e59f5de08bc8 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 14:59:55 +0000 Subject: [PATCH 11/15] test(channels): update test-channel-modal-e2e.js for the "+ Add" chip and #1111; register in CI (#189) Two of its 14 steps were stale, not regressions: - 70855249 (#1227, #1224 mobile UX) shortened the sidebar button to a "+ Add" chip; the accessible name is still "Add channel". The step now checks visibility, the chip text and the aria-label. - 12d96a9d (#1111) hides the My Channels section while this browser holds no key, so waiting for all three sections on a fresh page timed out. The step now expects Network + Encrypted and no My Channels, and a new last step expects My Channels with the channel the test added. Kept (not deleted) because two steps are not covered elsewhere in CI: clicking the close button hides the modal, and a valid PSK is persisted under corescope_channel_keys. Registered in the Playwright step against the fixture server. Fork guards unchanged (9). Mutants (channels.js, served from a copy): My Channels rendered while empty; close button ignored; aria-label changed. Each turns it red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- .github/workflows/deploy.yml | 1 + test-channel-modal-e2e.js | 31 ++++++++++++++++++++++--------- test-test-all.js | 1 - 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 43c73125e..2cd7d3c14 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -528,6 +528,7 @@ jobs: CHROMIUM_REQUIRE=1 node test-packet-trace-alignment-e2e.js 2>&1 | tee -a e2e-output.txt # Root E2Es repaired or ported to plain playwright in #189. CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-pulse-ring-weight-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channel-modal-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/test-channel-modal-e2e.js b/test-channel-modal-e2e.js index 106776b73..60cc67218 100644 --- a/test-channel-modal-e2e.js +++ b/test-channel-modal-e2e.js @@ -6,7 +6,8 @@ * - sidebar [+ Add Channel] opens modal * - modal renders three labeled sections + privacy footer + QR placeholders * - close (โœ•) hides modal - * - sectioned sidebar renders My Channels / Network / Encrypted sections + * - sectioned sidebar renders Network / Encrypted sections, and My Channels + * only once this browser holds a key (#1111) * - PSK add flow: invalid hex โ†’ error; valid hex โ†’ modal closes * * Usage: BASE_URL=http://localhost:38201 node test-channel-modal-e2e.js @@ -42,8 +43,13 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } }); await step('Add Channel button is visible', async () => { - const text = await page.textContent('#chAddChannelBtn'); - assert(/Add Channel/.test(text), 'button text: ' + text); + // #1227 (#1224) shortened the label to a "+ Add" chip; the accessible + // name is still "Add channel". + assert(await page.isVisible('#chAddChannelBtn'), '#chAddChannelBtn not visible'); + const text = (await page.textContent('#chAddChannelBtn')).trim(); + assert(/^\+\s*Add\b/.test(text), 'button text: ' + text); + const label = await page.getAttribute('#chAddChannelBtn', 'aria-label'); + assert(/^Add channel$/i.test(label || ''), 'aria-label: ' + label); }); await step('modal hidden on load', async () => { @@ -104,20 +110,20 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } }, { timeout: 3000 }); }); - await step('sidebar renders three sections (My Channels / Network / Encrypted)', async () => { + await step('sidebar renders Network + Encrypted, and no My Channels without a key (#1111)', async () => { // Wait for channel list to populate from API (or render empty-state). await page.waitForFunction(() => { const el = document.getElementById('chList'); if (!el) return false; - return el.querySelector('.ch-section-mychannels') && - el.querySelector('.ch-section-network') && + return el.querySelector('.ch-section-network') && el.querySelector('.ch-section-encrypted'); }, { timeout: 8000 }); const headers = await page.$$eval('.ch-section-header', els => els.map(e => e.textContent.trim())); const joined = headers.join(' | '); - assert(/My Channels/.test(joined), 'My Channels header missing: ' + joined); - assert(/Network/.test(joined), 'Network header missing'); - assert(/Encrypted/.test(joined), 'Encrypted header missing'); + assert(/Network/.test(joined), 'Network header missing: ' + joined); + assert(/Encrypted/.test(joined), 'Encrypted header missing: ' + joined); + // 12d96a9d (#1111): the My Channels section is hidden while empty. + assert(!(await page.$('.ch-section-mychannels')), 'My Channels must not render without a stored key: ' + joined); }); await step('Encrypted section is collapsed by default', async () => { @@ -154,6 +160,13 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } assert(/cafebabe/i.test(stored), 'expected stored key in localStorage corescope_channel_keys, got: ' + stored); }); + await step('sidebar now renders My Channels with the new channel', async () => { + await page.waitForFunction(() => { + const sec = document.querySelector('#chList .ch-section-mychannels'); + return sec && /My Channels/.test(sec.textContent) && /E2E Test Channel/.test(sec.textContent); + }, { timeout: 5000 }); + }); + await browser.close(); console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); diff --git a/test-test-all.js b/test-test-all.js index 932f9d12c..18d00cd6c 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -134,7 +134,6 @@ const KNOWN_UNREGISTERED = { // need something CI's unit job does not have 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E - 'test-channel-modal-e2e.js': 'red: Add button text and sidebar sections changed', 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', 'test-node-reach-e2e.js': 'red: #nqMap .leaflet-container never visible', 'test-path-inspector-e2e.js': 'needs @playwright/test (not a dependency)', From b932b40a6fa9b0368dcd9b10545ed62d1bdba4c3 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 15:02:20 +0000 Subject: [PATCH 12/15] test(reach): fix the map selector in test-node-reach-e2e.js; register in CI (#189) The Reach page E2E timed out waiting for '#nqMap .leaflet-container'. The map does render: L.map('nqMap') adds .leaflet-container to #nqMap itself, so a descendant selector can never match. The test has been red since it was added in e2212f50 (#1627); it was never registered. It now waits for '#nqMap.leaflet-container' (visible) and an attached map pane, and only when the node itself has a location, which is when node-reach.js builds the map. Overlap with test-node-reach-coverage-e2e.js: none in practice. That file tests the coverage layer only and skips in CI because the fixture has clientRxCoverage off. This file is the only CI test of the Reach endpoint shape, the toggles' row counts and the map. Mutants (node-reach.js, served from a copy): map never built; incoming and outgoing filters swapped. Each turns it red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- .github/workflows/deploy.yml | 1 + test-node-reach-e2e.js | 10 +++++++--- test-test-all.js | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2cd7d3c14..75f1bd8c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -529,6 +529,7 @@ jobs: # Root E2Es repaired or ported to plain playwright in #189. CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-pulse-ring-weight-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channel-modal-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-node-reach-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/test-node-reach-e2e.js b/test-node-reach-e2e.js index 639333dd8..a937207d5 100644 --- a/test-node-reach-e2e.js +++ b/test-node-reach-e2e.js @@ -65,9 +65,13 @@ async function getJson(page, url) { if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href); } - // Map must render whenever at least one link has GPS (no swallowed failure). - if (reach.links.some(l => l.lat != null && l.lon != null)) { - await page.waitForSelector('#nqMap .leaflet-container', { timeout: 10000 }); + // Map must render whenever the node and at least one link have GPS (no + // swallowed failure). node-reach.js only builds the map when the node + // itself has a location. L.map('nqMap') puts .leaflet-container on #nqMap + // itself, so the selector is a compound one, not a descendant one. + if (reach.node.lat != null && reach.links.some(l => l.lat != null && l.lon != null)) { + await page.waitForSelector('#nqMap.leaflet-container', { timeout: 10000 }); + await page.waitForSelector('#nqMap .leaflet-map-pane', { state: 'attached', timeout: 10000 }); } } diff --git a/test-test-all.js b/test-test-all.js index 18d00cd6c..223dd655d 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -135,7 +135,6 @@ const KNOWN_UNREGISTERED = { 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', - 'test-node-reach-e2e.js': 'red: #nqMap .leaflet-container never visible', 'test-path-inspector-e2e.js': 'needs @playwright/test (not a dependency)', 'test-rx-coverage-mobile-nav-e2e.js': 'skips while clientRxCoverage is off (the default)', }; From 5e9f91ae5239d63a57ae025690a7202c09185b03 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 15:06:36 +0000 Subject: [PATCH 13/15] test(path-inspector): port the map-pane E2E to plain playwright; register in CI (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-path-inspector-e2e.js was an @playwright/test spec (not a dependency), so it never ran. Its fixed prefixes 2c,a1 also have no candidates in the CI fixture, so the Show on Map cases could not pass there anyway. The port keeps what nothing else in CI covers: #mapSidePane collapsed on load and expanded by the toggle; the pane's prefix search renders one row per API candidate; Show on Map draws the route (.mc-rt-edge) and enters route view; the Tools landing links to the Path Inspector and to Trace. The prefix pair is found through /api/paths/inspect (bounded at 400 probes) instead of being hard-coded. Dropped, covered elsewhere: the standalone deep link (test-path-inspector-coverage-e2e.js, "deep link ?prefixes=2c โ€ฆ") and the #/traces redirect (test-issue-1883-redirect-history.js). "Switching candidate clears prior polyline" asserted nothing. The coverage file's header, which still called this spec unwired, is updated. Registered in the Playwright step. Fork guards unchanged (9). Mutants (served from a copy): Show on Map draws nothing (map.js); pane results always "No candidates" (map.js); pane rendered expanded (map.js); Tools landing link renamed (app.js). Each turns it red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- .github/workflows/deploy.yml | 1 + test-path-inspector-coverage-e2e.js | 9 +- test-path-inspector-e2e.js | 198 +++++++++++++++++----------- test-test-all.js | 1 - 4 files changed, 126 insertions(+), 83 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 75f1bd8c8..f47ed565f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -530,6 +530,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-pulse-ring-weight-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channel-modal-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-node-reach-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/test-path-inspector-coverage-e2e.js b/test-path-inspector-coverage-e2e.js index 33ce8db36..97e750a0a 100644 --- a/test-path-inspector-coverage-e2e.js +++ b/test-path-inspector-coverage-e2e.js @@ -1,11 +1,10 @@ #!/usr/bin/env node /* Coverage E2E for public/path-inspector.js (#1297 B5). * - * The existing test-path-inspector-e2e.js uses @playwright/test runner - * which is not wired into CI's `e2e-test` step (CI runs raw - * `node test-โ€ฆ-e2e.js`). This file uses the plain chromium-launch - * pattern compatible with CI and exercises the standalone tools page - * surface end-to-end: + * test-path-inspector-e2e.js covers the map side pane and the Tools + * landing (ported from @playwright/test in #189). This file uses the + * plain chromium-launch pattern compatible with CI and exercises the + * standalone tools page surface end-to-end: * * - navigate to /#/tools/path-inspector * - assert page chrome renders (input, submit btn, help text) diff --git a/test-path-inspector-e2e.js b/test-path-inspector-e2e.js index ddf16c453..a17afa3ef 100644 --- a/test-path-inspector-e2e.js +++ b/test-path-inspector-e2e.js @@ -1,87 +1,131 @@ -// E2E tests for Path Inspector (spec ยง5 โ€” Playwright). -// Run: npx playwright test test-path-inspector-e2e.js -// Requires: running server on BASE_URL (default http://localhost:3000). +/** + * E2E (Path Inspector spec ยง2.7 / ยง2.8, ported for #189): the Path Inspector + * side pane on the map page, and the Tools landing. + * + * Previously an @playwright/test spec; that runner is not a dependency, so it + * never ran. This port uses the plain `playwright` API like the other + * *-e2e.js files and keeps the cases nothing else in CI covers: + * 1. #mapSidePane is visible and collapsed on load; the toggle expands it; + * 2. submitting prefixes in the pane renders a candidate table; + * 3. "Show on Map" draws the candidate route (an .mc-rt-edge path) and + * enters route view; + * 4. the Tools landing links to the Path Inspector and to Trace. + * + * Dropped from the old spec, covered elsewhere in CI: + * - standalone deep link ?prefixes= auto-fills and runs โ†’ + * test-path-inspector-coverage-e2e.js ("deep link ?prefixes=2c โ€ฆ"); + * - #/traces/ โ†’ #/tools/trace/ โ†’ test-issue-1883-redirect-history.js; + * - "switching candidate clears prior polyline" asserted nothing. + * + * The old spec used the fixed prefixes 2c,a1, which have no candidates in + * the CI fixture. The test now asks /api/paths/inspect for a two-hop pair + * of repeater prefixes that does (bounded search). + * + * Usage: BASE_URL=http://localhost:13581 node test-path-inspector-e2e.js + */ 'use strict'; +const { chromium } = require('playwright'); -const { test, expect } = require('@playwright/test'); -const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const MAX_PROBES = 400; // bounded /api/paths/inspect calls for the pair search -test.describe('Path Inspector โ€” Map Side Pane (spec ยง2.7)', () => { - test('side pane present and collapsed by default', async ({ page }) => { - await page.goto(`${BASE_URL}/#/map`); - const pane = page.locator('#mapSidePane'); - await expect(pane).toBeVisible(); - await expect(pane).not.toHaveClass(/expanded/); - }); +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'); } - test('click toggle expands the pane', async ({ page }) => { - await page.goto(`${BASE_URL}/#/map`); - await page.click('#mapPaneToggle'); - const pane = page.locator('#mapSidePane'); - await expect(pane).toHaveClass(/expanded/); - }); +// A pair of 1-byte repeater prefixes with at least one candidate path. +async function findCandidatePair(request) { + const nodes = await (await request.get(BASE + '/api/nodes?role=repeater&limit=500')).json(); + const prefixes = [...new Set((nodes.nodes || []).map((n) => n.public_key.slice(0, 2).toLowerCase()))].sort(); + let probes = 0; + for (const a of prefixes) { + for (const b of prefixes) { + if (a === b) continue; + if (++probes > MAX_PROBES) return null; + const res = await request.post(BASE + '/api/paths/inspect', { data: { prefixes: [a, b] } }); + if (!res.ok()) continue; + const body = await res.json(); + if (body.candidates && body.candidates.length) return { prefixes: a + ',' + b, count: body.candidates.length }; + } + } + return null; +} - test('submit valid prefixes renders candidates within 1s', async ({ page }) => { - await page.goto(`${BASE_URL}/#/map`); - await page.click('#mapPaneToggle'); - await page.fill('#mapPiInput', '2c,a1,f4'); - await page.click('#mapPiSubmit'); - // Wait for results or error (both indicate API round-trip complete). - await expect(page.locator('#mapPiResults table, #mapPiResults .no-results, #mapPiError')).toBeVisible({ timeout: 1000 }); - }); +(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'], + }); + } catch (err) { + if (requireChromium) { + console.error('test-path-inspector-e2e.js: FAIL โ€” Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-path-inspector-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); - test('Show on Map button draws polyline on map', async ({ page }) => { - await page.goto(`${BASE_URL}/#/map`); - await page.click('#mapPaneToggle'); - await page.fill('#mapPiInput', '2c,a1'); - await page.click('#mapPiSubmit'); - // Wait for results. - const btn = page.locator('#mapPiResults button[data-idx="0"]'); - await btn.waitFor({ timeout: 2000 }); - await btn.click(); - // Check that route layer has SVG polyline paths drawn. - const svg = page.locator('#leaflet-map .leaflet-overlay-pane svg path'); - await expect(svg.first()).toBeVisible({ timeout: 2000 }); - }); + console.log('\n=== Path Inspector map pane + Tools landing E2E against ' + BASE + ' ==='); + try { + const pair = await findCandidatePair(ctx.request); - test('switching candidate clears prior polyline', async ({ page }) => { - await page.goto(`${BASE_URL}/#/map`); - await page.click('#mapPaneToggle'); - await page.fill('#mapPiInput', '2c,a1'); - await page.click('#mapPiSubmit'); - const btn0 = page.locator('#mapPiResults button[data-idx="0"]'); - await btn0.waitFor({ timeout: 2000 }); - await btn0.click(); - // Click second candidate if available. - const btn1 = page.locator('#mapPiResults button[data-idx="1"]'); - if (await btn1.isVisible()) { - await btn1.click(); - // Prior route should be cleared โ€” only one polyline group visible. - } - }); -}); + await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#mapSidePane', { state: 'visible' }); + + await step('side pane is visible and collapsed on load', async () => { + const cls = await page.getAttribute('#mapSidePane', 'class'); + assert(!/\bexpanded\b/.test(cls), 'pane should start collapsed, class="' + cls + '"'); + }); + + await step('clicking the toggle expands the pane', async () => { + await page.click('#mapPaneToggle'); + await page.waitForFunction(() => document.getElementById('mapSidePane').classList.contains('expanded')); + assert(await page.isVisible('#mapPiInput'), '#mapPiInput not visible in the expanded pane'); + }); + + await step('submitting prefixes renders a candidate table' + (pair ? ' (' + pair.prefixes + ')' : ''), async () => { + assert(pair, 'no repeater prefix pair with candidates found in ' + MAX_PROBES + ' probes'); + await page.fill('#mapPiInput', pair.prefixes); + await page.click('#mapPiSubmit'); + await page.waitForSelector('#mapPiResults .path-inspector-table'); + const err = ((await page.textContent('#mapPiError')) || '').trim(); + assert(!err, 'unexpected error: ' + err); + const rows = await page.locator('#mapPiResults button[data-idx]').count(); + assert(rows === pair.count, 'rendered ' + rows + ' candidates, API returned ' + pair.count); + }); -test.describe('Path Inspector โ€” Standalone Page', () => { - test('deep link auto-fills and runs', async ({ page }) => { - await page.goto(`${BASE_URL}/#/tools/path-inspector?prefixes=2c,a1,f4`); - const input = page.locator('#path-inspector-input'); - await expect(input).toHaveValue('2c,a1,f4'); - // Should auto-submit and show results or error. - await expect(page.locator('#path-inspector-results table, #path-inspector-results .no-results, #path-inspector-error')).toBeVisible({ timeout: 2000 }); - }); + await step('"Show on Map" draws the candidate route', async () => { + assert(pair, 'no candidate to show'); + await page.click('#mapPiResults button[data-idx="0"]'); + await page.waitForSelector('#leaflet-map .leaflet-overlay-pane path.mc-rt-edge', { state: 'visible' }); + assert(await page.evaluate(() => document.body.classList.contains('mc-route-active')), 'route view not active'); + }); - test('old #/traces/ redirects to #/tools/trace/', async ({ page }) => { - await page.goto(`${BASE_URL}/#/traces/abc123`); - await page.waitForTimeout(500); - expect(page.url()).toContain('#/tools/trace/abc123'); - }); -}); + await step('Tools landing links to the Path Inspector and to Trace', async () => { + await page.goto(BASE + '/#/tools', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.tools-landing', { state: 'visible' }); + assert(await page.isVisible('.tools-landing a[href="#/tools/path-inspector"]'), 'Path Inspector entry missing'); + assert(await page.isVisible('.tools-landing a[href^="#/tools/trace"]'), 'Trace entry missing'); + }); -test.describe('Path Inspector โ€” Tools Landing (spec ยง2.8)', () => { - test('Tools nav shows landing with both entries', async ({ page }) => { - await page.goto(`${BASE_URL}/#/tools`); - await expect(page.locator('.tools-landing')).toBeVisible(); - await expect(page.locator('a[href="#/tools/path-inspector"]')).toBeVisible(); - await expect(page.locator('a[href*="#/tools/trace"]')).toBeVisible(); - }); -}); + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + } finally { + await browser.close(); + } + console.log('\ntest-path-inspector-e2e.js: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-test-all.js b/test-test-all.js index 223dd655d..058f87c92 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -135,7 +135,6 @@ const KNOWN_UNREGISTERED = { 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', - 'test-path-inspector-e2e.js': 'needs @playwright/test (not a dependency)', 'test-rx-coverage-mobile-nav-e2e.js': 'skips while clientRxCoverage is off (the default)', }; From ec3877cfcd819533de22963ff5268fc35631fa20 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 15:07:41 +0000 Subject: [PATCH 14/15] test(traces): port the #1522 trace URL sync spec to plain playwright; register in CI (#189) test-issue-1522-trace-url-sync-e2e.js was an @playwright/test spec (not a dependency), so the #1523 fix (doTrace() writes #/tools/trace/ with replaceState) had no running test. test-e2e-playwright.js only checks that the Trace page loads and returns results. Ported to the plain playwright pattern. Both cases are kept and made stricter: clicking Trace must issue GET /api/traces/, put the hash in the URL and not grow history (replaceState, not pushState); a deep link must pre-fill the input, actually run the trace and keep the URL. Registered in the Playwright step. Fork guards unchanged (9). Mutants (traces.js, served from a copy): replaceState removed; replaceState -> pushState; deep-link auto-trace removed. Each turns it red. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- .github/workflows/deploy.yml | 1 + test-issue-1522-trace-url-sync-e2e.js | 107 ++++++++++++++++++++------ test-test-all.js | 1 - 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f47ed565f..bc13dfdaa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -531,6 +531,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channel-modal-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-node-reach-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1522-trace-url-sync-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so diff --git a/test-issue-1522-trace-url-sync-e2e.js b/test-issue-1522-trace-url-sync-e2e.js index 4a9fa6cd9..99c482716 100644 --- a/test-issue-1522-trace-url-sync-e2e.js +++ b/test-issue-1522-trace-url-sync-e2e.js @@ -1,26 +1,89 @@ -// E2E test for issue #1522 โ€” trace hash written into URL after doTrace(). -// Run: npx playwright test test-issue-1522-trace-url-sync-e2e.js -// Requires: running server on BASE_URL (default http://localhost:3000). +/** + * E2E (#1522 / #1523, ported for #189): the Trace tool keeps the packet hash + * in the URL. + * + * 1. Typing a hash on #/tools/trace/ and clicking Trace writes it into the + * URL (#/tools/trace/) with replaceState, so the URL can be shared + * and Back does not step through each trace. + * 2. Opening #/tools/trace/ directly pre-fills the input, runs the + * trace (GET /api/traces/) and leaves the URL as it was. + * + * Previously an @playwright/test spec; that runner is not a dependency, so it + * never ran. This port uses the plain `playwright` API like the other + * *-e2e.js files. The hashes are made up: the tests are about the URL, and + * an unknown hash takes the same doTrace() path. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1522-trace-url-sync-e2e.js + */ 'use strict'; +const { chromium } = require('playwright'); -const { test, expect } = require('@playwright/test'); -const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const BASE = process.env.BASE_URL || 'http://localhost:13581'; -test.describe('Trace URL sync โ€” issue #1522', () => { - test('hash written into URL after clicking Trace', async ({ page }) => { - await page.goto(`${BASE_URL}/#/tools/trace/`); - await page.waitForSelector('#traceHashInput', { timeout: 8000 }); - await page.fill('#traceHashInput', 'deadbeef'); - await page.click('#traceBtn'); - await expect(page).toHaveURL(/#\/tools\/trace\/deadbeef$/); - }); +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'); } +const traceRequest = (hash) => (r) => new URL(r.url()).pathname === '/api/traces/' + hash; - test('deep-link pre-fills input and fires trace on load', async ({ page }) => { - await page.goto(`${BASE_URL}/#/tools/trace/cafebabe`); - await page.waitForSelector('#traceHashInput', { timeout: 8000 }); - const value = await page.inputValue('#traceHashInput'); - expect(value).toBe('cafebabe'); - // URL must remain unchanged (no extra replaceState pollution) - await expect(page).toHaveURL(/#\/tools\/trace\/cafebabe$/); - }); -}); +(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'], + }); + } catch (err) { + if (requireChromium) { + console.error('test-issue-1522-trace-url-sync-e2e.js: FAIL โ€” Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-issue-1522-trace-url-sync-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + + console.log('\n=== #1522 trace URL sync E2E against ' + BASE + ' ==='); + try { + await step('clicking Trace writes the hash into the URL (replaceState)', async () => { + await page.goto(BASE + '/#/tools/trace/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#traceHashInput'); + const historyBefore = await page.evaluate(() => history.length); + await page.fill('#traceHashInput', 'deadbeef'); + const traced = page.waitForRequest(traceRequest('deadbeef')); + await page.click('#traceBtn'); + await traced; + await page.waitForFunction(() => /#\/tools\/trace\/deadbeef$/.test(location.hash)); + const historyAfter = await page.evaluate(() => history.length); + assert(historyAfter === historyBefore, 'history grew from ' + historyBefore + ' to ' + historyAfter + ' (pushState instead of replaceState?)'); + }); + + await step('a deep link pre-fills the input, runs the trace and keeps the URL', async () => { + const traced = page.waitForRequest(traceRequest('cafebabe')); + await page.goto(BASE + '/#/tools/trace/cafebabe', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#traceHashInput'); + assert((await page.inputValue('#traceHashInput')) === 'cafebabe', 'input not pre-filled'); + await traced; + // The trace finished rendering (an unknown hash renders .trace-empty). + await page.waitForSelector('#traceResults .trace-empty'); + const hash = await page.evaluate(() => location.hash); + assert(/^#\/tools\/trace\/cafebabe$/.test(hash), 'URL changed to ' + hash); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + } finally { + await browser.close(); + } + console.log('\ntest-issue-1522-trace-url-sync-e2e.js: ' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-test-all.js b/test-test-all.js index 058f87c92..1923b9a6a 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -134,7 +134,6 @@ const KNOWN_UNREGISTERED = { // need something CI's unit job does not have 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', // E2E - 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', 'test-rx-coverage-mobile-nav-e2e.js': 'skips while clientRxCoverage is off (the default)', }; From c9c085bf598a163d708d87733b373e3af58b55e0 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 15:09:05 +0000 Subject: [PATCH 15/15] test(test-all): record why test-touch-targets.js stays unregistered (#189) The 48 vs 44 px decision for .nav-btn and .ch-icon-btn is left to the maintainer: #85 kept 44 (WCAG 2.5.5), and upstream later closed 2052 with 48 (#2078). The harness is also stale independently of that decision (no viewport meta, so mobile-only controls render 0x0; it still lists .compare-btn, removed in #1646). Reason text only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YY43YSM5M2JfDKYYxociMv --- test-test-all.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-test-all.js b/test-test-all.js index 1923b9a6a..08af253e7 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -132,7 +132,7 @@ const KNOWN_UNREGISTERED = { // red unit tests (stale after intended UI changes) 'test-packets.js': 'red on 1 assertion: collapsed group caret is #ph-caret-up, a real bug (#189); register once packets.js is fixed', // need something CI's unit job does not have - 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', + 'test-touch-targets.js': 'red in Chromium: 48px (upstream #2078) vs 44px (#85) is pending a decision (#189); harness also stale', // E2E 'test-rx-coverage-mobile-nav-e2e.js': 'skips while clientRxCoverage is off (the default)', };