From 3533318cbd2de1634c263d66d4b56bd537d0ba31 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 11:24:37 +0000 Subject: [PATCH 1/3] test(channels): reproduce overlapping channel-list loads and the missing mobile unread badge (#154, #155) Unit (test-channels-client-state-152.js, real channels.js in the vm harness, api() timing controlled per request): - #154: a late older region response must not replace the newer list, must not reconcile the selection away, and a stale failure must not paint "Failed to load channels"; a dropped init() load must resolve only after the newest list rendered, so a deep link opens against it. Guard: the #152 client-state merge still applies to the request that renders. - #155: the #1367 mobile row renders the unread badge (99+ cap, title, aria-label, none at 0); a live-decrypted message for a closed PSK channel bumps it and opening the channel clears it. Guard: desktop badge markup unchanged. Harness: mobile option for matchMedia, the debounced WS batch handler is captured, one fake child per querySelector selector, deferred() rejects. E2E (test-channels-154-155-e2e.js): a held-back region=SJC response lands after the SJC+SFO list (desktop and 390 px), and live PSK frames injected through page.routeWebSocket at 390 px and 1280 px. On the #166 head: unit 6 of the 8 new tests fail (the 2 guards pass); E2E 3 of 8 steps fail (#154 desktop + mobile, #155 mobile badge). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-channels-154-155-e2e.js | 227 ++++++++++++++++++++++++++++++ test-channels-client-state-152.js | 190 ++++++++++++++++++++++++- 3 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 test-channels-154-155-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8d2c1dd22..03a922805 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -411,6 +411,7 @@ jobs: BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channels-client-state-152-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-154-155-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-123-map-lifecycle-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-channels-154-155-e2e.js b/test-channels-154-155-e2e.js new file mode 100644 index 000000000..8591ff11c --- /dev/null +++ b/test-channels-154-155-e2e.js @@ -0,0 +1,227 @@ +/** + * #154 + #155 — E2E on the Channels page. + * + * #154: overlapping loadChannels() calls. The /api/channels?region=SJC + * response is held back ~2.5 s by a Playwright route; before it lands, the + * SFO pill is added (region SJC,SFO). The SJC,SFO list renders first, then + * the late SJC response arrives. Before the fix it replaced the list, so the + * pills showed SJC+SFO while the sidebar showed the SJC set (no #chat). The + * newer list must stay, and each pill click sends exactly one request. + * Desktop and mobile width. + * + * #155: unread badge on the #1367 mobile rows (390 px). A test PSK is added + * through the Add Channel modal, another channel is opened, and live + * client-decryptable GRP_TXT frames for the PSK are injected into the page's + * real WebSocket (page.routeWebSocket, forwarded to the server otherwise). + * The PSK row must show the badge, update it, and clear it when the channel + * is opened. The same steps run at desktop width as a control. + * + * Usage: BASE_URL=http://localhost:13581 node test-channels-154-155-e2e.js + */ +'use strict'; + +const { chromium } = require('playwright'); +const { + channelHashByteFor, buildPlaintext, encryptECB, computeMac, +} = require('./test-channels-client-state-152-decrypt-e2e.js'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const PSK_KEY = '0155c0de0155c0de0155c0de0155c0de'; +const PSK_HASH = 'user:psk:0155c0de'; +const PSK_LABEL = 'Issue155 Team'; +const SJC_DELAY_MS = 2500; + +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'); } + +function isChannelListRequest(url) { + const u = new URL(url); + return u.pathname === '/api/channels'; +} + +async function openChannelsPage(ctx) { + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#chList .ch-item, #chList .ch-row', { timeout: 10000 }); + return page; +} + +function rowSelector(hash) { return '#chList [data-hash="' + hash + '"]'; } + +async function runRace(browser, vp) { + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + // Hold back exactly the SJC-only list; every other request goes through. + await ctx.route('**/api/channels?**', async (route) => { + const u = new URL(route.request().url()); + if (u.pathname === '/api/channels' && u.searchParams.get('region') === 'SJC') { + await new Promise((r) => setTimeout(r, SJC_DELAY_MS)); + } + await route.continue(); + }); + const page = await openChannelsPage(ctx); + const listRequests = []; + page.on('request', (r) => { if (isChannelListRequest(r.url())) listRequests.push(new URL(r.url()).searchParams.get('region')); }); + + await step(vp.name + ': #154 a late SJC response does not replace the SJC+SFO list', async () => { + const sjcPill = page.locator('#chRegionFilter [data-region="SJC"]'); + const sfoPill = page.locator('#chRegionFilter [data-region="SFO"]'); + await sjcPill.waitFor({ state: 'attached' }); + const sjcResponse = page.waitForResponse((r) => isChannelListRequest(r.url()) && new URL(r.url()).searchParams.get('region') === 'SJC', { timeout: SJC_DELAY_MS + 8000 }); + await sjcPill.click(); + await page.waitForFunction(() => window.RegionFilter && window.RegionFilter.getRegionParam() === 'SJC'); + const bothResponse = page.waitForResponse((r) => { + if (!isChannelListRequest(r.url())) return false; + const region = new URL(r.url()).searchParams.get('region') || ''; + return region.indexOf('SJC') !== -1 && region.indexOf('SFO') !== -1; + }); + await sfoPill.click(); + await bothResponse; + // The SJC+SFO list renders while the SJC response is still held back. + await page.waitForSelector(rowSelector('#chat'), { timeout: 3000 }); + await sjcResponse; + await page.waitForTimeout(500); + const s = await page.evaluate(() => ({ + region: window.RegionFilter.getRegionParam(), + hashes: Array.from(document.querySelectorAll('#chList [data-hash]')).map((e) => e.getAttribute('data-hash')), + })); + assert(s.region.indexOf('SJC') !== -1 && s.region.indexOf('SFO') !== -1, 'region stays SJC+SFO, got ' + s.region); + assert(s.hashes.indexOf('#chat') !== -1, 'the SJC+SFO list (with #chat) must stay rendered, got ' + JSON.stringify(s.hashes)); + assert(listRequests.length === 2, 'exactly one /api/channels request per pill click, got ' + JSON.stringify(listRequests)); + }); + + await ctx.close(); +} + +// A live GRP_TXT frame the server would broadcast, encrypted with PSK_KEY so +// only this browser can decrypt it. +function livePskFrame(n, sender, text) { + const key = Buffer.from(PSK_KEY, 'hex'); + const ciphertext = encryptECB(key, buildPlaintext(Math.floor(Date.now() / 1000), 0, sender + ': ' + text)); + return JSON.stringify({ + type: 'packet', + data: { + id: 1550000 + n, + hash: 'e2e155' + String(n).padStart(10, '0'), + decoded: { + header: { payloadTypeName: 'GRP_TXT' }, + payload: { + type: 'GRP_TXT', + channelHash: channelHashByteFor(key), + encryptedData: ciphertext.toString('hex'), + mac: computeMac(key, ciphertext).toString('hex'), + }, + }, + }, + }); +} + +async function badgeState(page) { + return page.evaluate((hash) => { + const row = document.querySelector('#chList [data-hash="' + hash + '"]'); + const badge = row && row.querySelector('.ch-unread-badge'); + if (!badge) return { row: !!row, rowClass: row ? row.className : '', badge: null }; + const rb = row.getBoundingClientRect(); + const bb = badge.getBoundingClientRect(); + return { + row: true, + rowClass: row.className, + badge: { + text: badge.textContent, + title: badge.getAttribute('title'), + aria: badge.getAttribute('aria-label'), + inLine1: !!badge.closest('.ch-row-line1, .ch-item-top'), + visible: bb.width > 0 && bb.height > 0 && getComputedStyle(badge).visibility !== 'hidden', + insideRow: bb.left >= rb.left && bb.right <= rb.right + 0.5, + insideViewport: bb.right <= window.innerWidth + 0.5, + }, + }; + }, PSK_HASH); +} + +async function backToListIfMobile(page, vp) { + if (!vp.mobile) return; + await page.click('.ch-back'); + await page.waitForSelector('#chList .ch-row', { state: 'visible' }); +} + +async function runUnread(browser, vp) { + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + let pageSocket = null; + await ctx.routeWebSocket(/.*/, (ws) => { + ws.connectToServer(); + pageSocket = ws; + }); + const page = await openChannelsPage(ctx); + const expectedRowClass = vp.mobile ? 'ch-row' : 'ch-item'; + + await step(vp.name + ': #155 setup — add a PSK, then open another channel', async () => { + await page.click('#chAddChannelBtn'); + await page.fill('#chPskKey', PSK_KEY); + await page.fill('#chPskName', PSK_LABEL); + await page.click('#chPskAddBtn'); + await page.waitForFunction((hash) => location.hash === '#/channels/' + encodeURIComponent(hash), PSK_HASH, { timeout: 10000 }); + await backToListIfMobile(page, vp); + await page.click(rowSelector('#test')); + await page.waitForFunction(() => location.hash === '#/channels/' + encodeURIComponent('#test')); + await backToListIfMobile(page, vp); + assert(pageSocket, 'the page opened its WebSocket'); + const s = await badgeState(page); + assert(s.row && s.rowClass.split(' ').indexOf(expectedRowClass) !== -1, 'PSK row rendered as .' + expectedRowClass + ', got ' + JSON.stringify(s)); + assert(s.badge === null, 'no badge before live traffic, got ' + JSON.stringify(s.badge)); + }); + + await step(vp.name + ': #155 a live-decrypted message for the closed PSK shows the badge, a second one updates it', async () => { + pageSocket.send(livePskFrame(1, 'Bob', 'first live')); + await page.waitForFunction((hash) => { + const b = document.querySelector('#chList [data-hash="' + hash + '"] .ch-unread-badge'); + return b && b.textContent === '1'; + }, PSK_HASH, { timeout: 8000 }); + let s = await badgeState(page); + assert(s.badge.title === '1 new' && s.badge.aria === '1 unread', 'title/aria-label, got ' + JSON.stringify(s.badge)); + assert(s.badge.inLine1 && s.badge.visible && s.badge.insideRow && s.badge.insideViewport, 'badge visible inside the row, got ' + JSON.stringify(s.badge)); + pageSocket.send(livePskFrame(2, 'Bob', 'second live')); + await page.waitForFunction((hash) => { + const b = document.querySelector('#chList [data-hash="' + hash + '"] .ch-unread-badge'); + return b && b.textContent === '2'; + }, PSK_HASH, { timeout: 8000 }); + s = await badgeState(page); + assert(s.badge.aria === '2 unread', 'badge updated, got ' + JSON.stringify(s.badge)); + }); + + await step(vp.name + ': #155 opening the PSK channel clears the badge', async () => { + await page.click(rowSelector(PSK_HASH)); + await page.waitForFunction((hash) => location.hash === '#/channels/' + encodeURIComponent(hash), PSK_HASH); + await backToListIfMobile(page, vp); + const s = await badgeState(page); + assert(s.row && s.badge === null, 'badge cleared, got ' + JSON.stringify(s)); + }); + + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await ctx.close(); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log('\n=== #154 + #155 E2E against ' + BASE + ' ==='); + const desktop = { name: 'desktop', width: 1280, height: 800, mobile: false }; + const mobile = { name: 'mobile', width: 390, height: 844, mobile: true }; + await runRace(browser, desktop); + await runRace(browser, mobile); + await runUnread(browser, mobile); + await runUnread(browser, desktop); + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + await browser.close(); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error('FATAL:', e); process.exit(1); }); diff --git a/test-channels-client-state-152.js b/test-channels-client-state-152.js index fcd21ea31..c040c2eca 100644 --- a/test-channels-client-state-152.js +++ b/test-channels-client-state-152.js @@ -78,9 +78,9 @@ function makeSkewedDate(skewMs) { } function deferred() { - let resolve; - const promise = new Promise((r) => { resolve = r; }); - return { promise, resolve }; + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; } async function flush(n) { @@ -107,7 +107,9 @@ function makeHarness(opts) { style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, addEventListener() {}, removeEventListener() {}, - querySelector() { return makeFakeEl(); }, + // One child per selector, so a test can read what the page wrote to + // e.g. #chHeader .ch-header-text. + querySelector(sel) { this._q = this._q || {}; return this._q[sel] || (this._q[sel] = makeFakeEl()); }, querySelectorAll() { return []; }, getAttribute() { return null; }, setAttribute() {}, removeAttribute() {}, getBoundingClientRect() { return { width: 240, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; }, @@ -143,7 +145,9 @@ function makeHarness(opts) { window: { addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); }, removeEventListener() {}, - matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + // opts.mobile: the (max-width: 767px) query matches, so + // renderChannelList() uses the #1367 mobile rows (#155). + matchMedia: () => ({ matches: !!opts.mobile, addEventListener() {}, removeEventListener() {} }), }, document: { readyState: 'complete', @@ -193,7 +197,9 @@ function makeHarness(opts) { // app.js / roles.js stubs. ctx.onWS = () => {}; ctx.offWS = () => {}; - ctx.debouncedOnWS = (fn) => fn; + // h.wsHandler: the batch handler init() registers (the real one, with the + // live PSK decrypt and the unread bump), called without the debounce. + ctx.debouncedOnWS = (fn) => { h.wsHandler = fn; return fn; }; ctx.debounce = (fn) => fn; ctx.invalidateApiCache = () => {}; ctx.api = (p) => { @@ -1544,6 +1550,178 @@ async function test(name, fn) { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + // ── #154: overlapping loadChannels() — only the newest request renders ── + console.log('\n=== #154 overlapping loadChannels(): the newest request wins ==='); + + // A /channels responder that parks every request on its own deferred, + // keyed by request order, so a test decides which response lands first. + function parkChannelRequests(h) { + const parked = []; + h.respondChannels = (p) => { const d = deferred(); parked.push({ path: p, d }); return d.promise; }; + return parked; + } + function listedHashes(h) { return h.state().channels.map((c) => c.hash); } + + await test('#154: an older region response that lands last does not replace the newer region\'s list', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + const before = h.channelRequests.length; + const parked = parkChannelRequests(h); + h.regionParam = 'SJC'; + h.regionChange(); + h.regionParam = 'SFO'; + h.regionChange(); + await flush(); + assert.strictEqual(h.channelRequests.length - before, 2, 'one /channels request per region change (got ' + (h.channelRequests.length - before) + ')'); + assert.ok(/region=SJC/.test(parked[0].path) && /region=SFO/.test(parked[1].path), 'requests are SJC then SFO'); + parked[1].d.resolve({ channels: [serverChannel('#sfo')] }); + await flush(); + assert.deepStrictEqual(listedHashes(h), ['#sfo'], 'the SFO list renders'); + parked[0].d.resolve({ channels: [serverChannel('#sjc')] }); + await flush(); + assert.deepStrictEqual(listedHashes(h), ['#sfo'], 'the late SJC response must not replace the SFO list (got ' + JSON.stringify(listedHashes(h)) + ')'); + assert.ok(/#sfo/.test(h.elements.chList.innerHTML) && !/#sjc/.test(h.elements.chList.innerHTML), 'the rendered list stays SFO'); + assert.strictEqual(h.channelRequests.length - before, 2, 'no extra /channels request'); + }); + + await test('#154: a dropped older response does not reconcile (close) the selection the newer list contains', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + const parked = parkChannelRequests(h); + const older = h.w._channelsLoadChannelsForTest(true); + const newer = h.w._channelsLoadChannelsForTest(true); + parked[1].d.resolve({ channels: [serverChannel('#sfo')] }); + await newer; + const msgs = [{ sender: 'A', text: 'kept', timestamp: '2026-01-01T00:00:00Z', packetHash: 'k1' }]; + h.setState({ selectedHash: '#sfo', messages: msgs }); + h.historyCalls.length = 0; + parked[0].d.resolve({ channels: [serverChannel('#sjc')] }); + await older; + const s = h.state(); + assert.strictEqual(s.selectedHash, '#sfo', 'selection must survive the dropped response (got ' + s.selectedHash + ')'); + assert.strictEqual(s.messages.length, 1, 'messages must survive the dropped response'); + assert.ok(!h.historyCalls.includes('#/channels'), 'URL must not be rewritten (got ' + JSON.stringify(h.historyCalls) + ')'); + }); + + await test('#154: a dropped older failure does not paint "Failed to load channels" over the newer list', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + const parked = parkChannelRequests(h); + const older = h.w._channelsLoadChannelsForTest(false); + const newer = h.w._channelsLoadChannelsForTest(true); + parked[1].d.resolve({ channels: [serverChannel('#sfo')] }); + await newer; + parked[0].d.promise.catch(() => {}); + parked[0].d.reject(new Error('offline')); + await older; + assert.ok(!/Failed to load channels/.test(h.elements.chList.innerHTML), 'stale failure must not replace the list'); + assert.ok(/#sfo/.test(h.elements.chList.innerHTML), 'the newer list stays rendered'); + }); + + await test('#154: a dropped call resolves only after the newest list rendered (init deep link opens with the right row)', async () => { + const h = makeHarness(); + const parked = parkChannelRequests(h); + const page = h.elements.page || (h.elements.page = h.ctx.document.getElementById('page')); + const initDone = h.page.init(page, 'sfo-room'); + await flush(); + h.regionParam = 'SFO'; + h.regionChange(); + await flush(); + // The first (unfiltered) response lands first, but a newer request is + // already running, so it is dropped; the deep link must wait for the + // SFO list, which is the one that has the room. + parked[0].d.resolve({ channels: [serverChannel('public')] }); + await flush(); + parked[1].d.resolve({ channels: [serverChannel('sfo-room', { messageCount: 7 })] }); + await initDone; + await flush(); + const header = h.elements.chHeader.querySelector('.ch-header-text').textContent; + assert.strictEqual(h.state().selectedHash, 'sfo-room', 'deep link selected'); + assert.strictEqual(header, 'sfo-room — 7 messages', 'deep link opened against the SFO list (got ' + JSON.stringify(header) + ')'); + assert.deepStrictEqual(listedHashes(h), ['sfo-room'], 'the SFO list renders'); + }); + + await test('#154: the #152 client-state merge still applies to the request that renders (WS update during it wins)', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public', { messageCount: 10, lastMessage: 'old snapshot' })] }); + await h.init(); + h.setState({ channels: [Object.assign({}, h.row('public'), { unread: 2 })] }); + const parked = parkChannelRequests(h); + const older = h.w._channelsLoadChannelsForTest(true); + const newer = h.w._channelsLoadChannelsForTest(true); + await flush(); + h.liveMessage('public', 'Carol', 'live during flight'); + const snapshot = { channels: [serverChannel('public', { messageCount: 10, lastSender: 'Server', lastMessage: 'snapshot' })] }; + parked[1].d.resolve(snapshot); + await newer; + parked[0].d.resolve(snapshot); + await older; + const row = h.row('public'); + assert.strictEqual(row.lastMessage, 'live during flight', 'live message kept (got ' + row.lastMessage + ')'); + assert.strictEqual(row.messageCount, 11, 'live count kept with its message'); + assert.strictEqual(row.unread, 2, 'unread carried'); + }); + + // ── #155: mobile channel rows show the unread badge ── + console.log('\n=== #155 mobile rows show the unread badge ==='); + + // The rendered #1367 mobile row for `hash` (one