From 4fc30fc71ddd90a0fb51ec67caf2bf4f8a25fbdf Mon Sep 17 00:00:00 2001 From: Openclaw Date: Thu, 24 Sep 2026 14:34:24 +0200 Subject: [PATCH 1/2] test(reach-rank): wait for the remounted board before typing; prove Back restores the search test-reach-rank-e2e.js failed now and then at "search restored after Back": 15/50 plain and 13/20 instrumented on master e51272d9 locally, and once in CI. The app was right. The test acted before the board it navigated to was ready: - page.goto() on a same-document hash resolves when the history entry commits. app.js re-renders on the later hashchange, so openBoard()'s waits could pass on the previous board. The query was then typed into a node that was about to be replaced. - One frame after each mount, app.js (#630-7) moves focus to the page heading. Typing across that frame lost the rest of the query. In both cases the search never ran. The unfiltered first page still held the target, so the step went on. Back then correctly returned to "#/reach-rank" without q. settleBoard() now waits, when the hash changes, for a #rrSearch that did not exist before, then for the #630-7 focus hand-off, then for the mount's first fetch. The keyboard step: - checks that the search really applied (field, hash and the API's filtered rows) before it navigates on; - after Back, checks field, hash and rows twice, with Forward in between, in the same document with an unchanged history.length. A route hold (installRouteHold/holdNextRoute) holds the router, and the frame callbacks it schedules, for the keyboard step's navigation until the next key press. settleBoard releases them only after seeing that its condition does not hold yet. Master's waits therefore fail every time, and nothing is timed. The test asserts that the hold caught app.js's router. Co-Authored-By: Claude Opus 5.5 --- test-reach-rank-e2e.js | 130 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/test-reach-rank-e2e.js b/test-reach-rank-e2e.js index a7031593f..e562e397b 100644 --- a/test-reach-rank-e2e.js +++ b/test-reach-rank-e2e.js @@ -8,7 +8,8 @@ * no-match and past-the-end states. * 3. Keyboard: search → Enter → Tab to a row link → Enter opens that node's * Reach page, whose Rank card matches the leaderboard (same snapshot) and - * links back; Back restores the search from the hash. + * links back; Back restores the search from the hash (field, URL and + * filtered rows), twice via Back/Forward, without reloading the page. * 4. Mobile 375×812: no horizontal scroll, all three columns visible. * 5. Hostile node names render as text (API response intercepted); a failed * API call shows an error, never an empty leaderboard. @@ -35,13 +36,98 @@ async function getJson(page, path) { return r.json(); } -async function openBoard(page, hash) { - await page.goto(BASE + '/#/reach-rank' + (hash || '')); +// Runs a navigation that should end on the leaderboard and waits until the +// board it produced is ready for input. A same-document hash navigation +// resolves page.goto()/goBack() as soon as the history entry commits, but +// app.js re-renders on the later hashchange, and one frame after each mount +// it moves focus to the page heading (#630-7). Acting earlier types into the +// board that is about to be replaced, or loses keystrokes to that focus move. +// So when the hash changes: wait for a #rrSearch that did not exist before, +// then for the focus hand-off; then for the mount's first fetch. When the hash +// does not change, the router does not re-render and the current board stays. +async function settleBoard(page, navigate) { + const before = await page.evaluate(() => { + const el = document.getElementById('rrSearch'); + if (el) el.__rrStale = true; + window.__rrDoc = window.__rrDoc || String(Math.random()); + return { hash: location.hash, doc: window.__rrDoc }; + }); + await navigate(); + const remounted = await page.evaluate(b => window.__rrDoc !== b.doc || location.hash !== b.hash, before); + if (remounted) { + await page.waitForFunction(() => { + const el = document.getElementById('rrSearch'); + if (el && !el.__rrStale) return true; + if (window.__rrFlushRoute) window.__rrFlushRoute(); // a held route (see installRouteHold) may run now + return false; + }).catch(() => { throw new Error('the leaderboard never re-rendered after navigating to ' + page.url()); }); + await page.waitForFunction(() => { + const app = document.getElementById('app'); + const target = app.querySelector('h1, h2, h3, [role="heading"]') || app; // app.js #630-7 + if (document.activeElement === target) return true; + if (window.__rrFlushFrames) window.__rrFlushFrames(); // the held route's frame callbacks may run now + return false; + }).catch(() => { throw new Error("app.js never moved focus to the leaderboard heading after navigating to " + page.url()); }); + } await page.waitForSelector('#rrRows tr'); await page.waitForFunction(() => document.getElementById('rrTable') && !document.getElementById('rrTable').hasAttribute('aria-busy')); } +async function openBoard(page, hash) { + await settleBoard(page, () => page.goto(BASE + '/#/reach-rank' + (hash || ''))); +} + +// Race amplifier for the keyboard step. In CI the router usually re-renders +// within a millisecond of a hash navigation committing, and moves focus one +// frame later, so acting too early failed only sometimes. installRouteHold() +// wraps app.js's router (the hashchange listener named 'navigate'); +// holdNextRoute() then holds the next route change, and after it runs, the +// animation-frame callbacks it scheduled (the focus hand-off), until the +// page's next key press. settleBoard releases them only after seeing that its +// condition does not hold yet (__rrFlushRoute, __rrFlushFrames). Typing before +// the board is ready therefore loses the search every time instead of now and +// then. Nothing is timed. +async function installRouteHold(page) { + await page.addInitScript(() => { + const add = window.addEventListener; + window.addEventListener = function (type, fn, opts) { + if (type !== 'hashchange' || typeof fn !== 'function' || fn.name !== 'navigate') return add.call(this, type, fn, opts); + window.__rrRouteHooked = true; + return add.call(this, type, function (e) { + if (!window.__rrHoldNext) return fn.call(this, e); + window.__rrHoldNext = false; + let pending = true; + const run = () => { + if (!pending) return; + pending = false; + window.__rrFlushRoute = null; + window.removeEventListener('keydown', run, true); + const raf = window.requestAnimationFrame; + const frames = []; + window.requestAnimationFrame = cb => { frames.push(cb); return 0; }; + try { fn.call(window, e); } finally { window.requestAnimationFrame = raf; } + if (!frames.length) return; + const runFrames = () => { + window.__rrFlushFrames = null; + window.removeEventListener('keydown', runFrames, true); + frames.splice(0).forEach(cb => cb(performance.now())); + }; + window.__rrFlushFrames = runFrames; + add.call(window, 'keydown', runFrames, true); + }; + window.__rrFlushRoute = run; + add.call(window, 'keydown', run, true); + }, opts); + }; + }); +} + +async function holdNextRoute(page) { + const hooked = await page.evaluate(() => { window.__rrHoldNext = true; return window.__rrRouteHooked === true; }); + assert(hooked, "the route hold did not catch app.js's hashchange router (listener named 'navigate')"); +} + async function tableRows(page) { return page.$$eval('#rrRows tr', trs => trs.map(tr => { const tds = tr.querySelectorAll('td'); @@ -99,6 +185,7 @@ async function main() { const LEAFLET_TEARDOWN = /reading '_leaflet_pos'/; const pageErrors = []; page.on('pageerror', e => { if (!LEAFLET_TEARDOWN.test(e.message)) pageErrors.push(e.message); }); + await installRouteHold(page); const api = await getJson(page, '/api/reach-rank'); console.log('reach-rank E2E: total=' + api.total + ' snapshot_at=' + api.snapshot_at); @@ -199,10 +286,29 @@ async function main() { await step('keyboard: search → Tab → Enter opens Reach; Rank card matches; Back restores search', async () => { const q = target.name ? target.name : target.pubkey.slice(0, 10); + const expect = await getJson(page, '/api/reach-rank?q=' + encodeURIComponent(q)); + const expectHrefs = expect.rows.map(r => '#/nodes/' + r.pubkey + '/reach'); + // The board shows the search, both in the field and in the URL, and lists + // exactly the API's matches for it. + const searchShown = async (when) => { + await page.waitForFunction(n => document.querySelectorAll('#rrRows a.nq-link').length === n && + !document.getElementById('rrTable').hasAttribute('aria-busy'), expect.rows.length) + .catch(() => { throw new Error('rows never matched the search for "' + q + '" ' + when); }); + const st = await page.evaluate(() => ({ hash: location.hash, value: document.getElementById('rrSearch').value, + hrefs: [...document.querySelectorAll('#rrRows a.nq-link')].map(a => a.getAttribute('href')) })); + assert(st.value === q, 'search field shows ' + JSON.stringify(st.value) + ', want ' + JSON.stringify(q) + ' ' + when); + assert(st.hash.includes('q=' + encodeURIComponent(q)), 'hash lacks the search ' + when + ': ' + st.hash); + assert(JSON.stringify(st.hrefs) === JSON.stringify(expectHrefs), 'rows are not the search results ' + when); + }; + // Hold the router for this navigation so acting on the old board fails + // deterministically (see installRouteHold). + await holdNextRoute(page); await openBoard(page); + assert(await page.evaluate(() => window.__rrHoldNext === false), 'openBoard did not change route, so the held navigation never happened'); await page.focus('#rrSearch'); await page.keyboard.type(q); await page.keyboard.press('Enter'); + await searchShown('after typing it and pressing Enter'); await page.waitForFunction(h => [...document.querySelectorAll('#rrRows a.nq-link')].some(a => a.getAttribute('href') === h), '#/nodes/' + target.pubkey + '/reach'); // Tab from the search box walks the rows' links in order. @@ -227,9 +333,21 @@ async function main() { const cardText = await page.$$eval('.analytics-stat-card', cs => cs.map(c => c.textContent).join('|')); assert(cardText.includes('#' + target.rank + ' / ' + board.total), 'Rank card text: ' + cardText); assert(await page.getAttribute('.nq-rank-link', 'href') === '#/reach-rank', 'View leaderboard link'); - await page.goBack(); - await page.waitForSelector('#rrSearch'); - assert(await page.inputValue('#rrSearch') === q, 'search restored after Back'); + // Back (twice, with Forward in between) must be a history traversal inside + // the same document, not a reload, and must bring the search back. + const reachUrl = page.url(); + const doc = await page.evaluate(() => ({ len: history.length, doc: window.__rrDoc })); + for (let round = 1; round <= 2; round++) { + await settleBoard(page, () => page.goBack()); + const after = await page.evaluate(() => ({ len: history.length, doc: window.__rrDoc })); + assert(after.doc === doc.doc && after.len === doc.len, 'Back ' + round + ' reloaded the page or changed history (' + JSON.stringify(after) + ' vs ' + JSON.stringify(doc) + ')'); + await searchShown('after Back ' + round); + assert(await page.inputValue('#rrSearch') === q, 'search restored after Back'); + if (round === 2) break; + await page.goForward(); + await page.waitForSelector('.nq-rank-link'); + assert(page.url() === reachUrl, 'Forward returned to ' + page.url()); + } }); await step('legacy empty-endpoint edges are not neighbours (fixture top node shows 9, not 10)', async () => { From fedee5204f6b5e5e6cfda17fde1ce3b2b6b44a83 Mon Sep 17 00:00:00 2001 From: Openclaw Date: Thu, 24 Sep 2026 14:56:30 +0200 Subject: [PATCH 2/2] reviewfix(test): name a reload on Back, keep the original error text Fixes the two findings from the independent review: - A reload on Back failed the step, but only as "Execution context was destroyed". The step now requires the document token before Back. When settleBoard fails, it waits for the current document to load and compares __rrDoc; a different or missing token raises "Back N reloaded the page instead of traversing history". The success path already compared the token. An event counter was tried first and dropped: the destroyed evaluate can come before the new document's DOMContentLoaded is counted. - The readiness and search waits keep the original error message when they fail, instead of replacing it. Co-Authored-By: Claude Opus 5.5 --- test-reach-rank-e2e.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test-reach-rank-e2e.js b/test-reach-rank-e2e.js index e562e397b..e11cab1a5 100644 --- a/test-reach-rank-e2e.js +++ b/test-reach-rank-e2e.js @@ -60,14 +60,14 @@ async function settleBoard(page, navigate) { if (el && !el.__rrStale) return true; if (window.__rrFlushRoute) window.__rrFlushRoute(); // a held route (see installRouteHold) may run now return false; - }).catch(() => { throw new Error('the leaderboard never re-rendered after navigating to ' + page.url()); }); + }).catch(e => { throw new Error('the leaderboard never re-rendered after navigating to ' + page.url() + ': ' + e.message); }); await page.waitForFunction(() => { const app = document.getElementById('app'); const target = app.querySelector('h1, h2, h3, [role="heading"]') || app; // app.js #630-7 if (document.activeElement === target) return true; if (window.__rrFlushFrames) window.__rrFlushFrames(); // the held route's frame callbacks may run now return false; - }).catch(() => { throw new Error("app.js never moved focus to the leaderboard heading after navigating to " + page.url()); }); + }).catch(e => { throw new Error('app.js never moved focus to the leaderboard heading after navigating to ' + page.url() + ': ' + e.message); }); } await page.waitForSelector('#rrRows tr'); await page.waitForFunction(() => document.getElementById('rrTable') && @@ -293,7 +293,7 @@ async function main() { const searchShown = async (when) => { await page.waitForFunction(n => document.querySelectorAll('#rrRows a.nq-link').length === n && !document.getElementById('rrTable').hasAttribute('aria-busy'), expect.rows.length) - .catch(() => { throw new Error('rows never matched the search for "' + q + '" ' + when); }); + .catch(e => { throw new Error('rows never matched the search for "' + q + '" ' + when + ': ' + e.message); }); const st = await page.evaluate(() => ({ hash: location.hash, value: document.getElementById('rrSearch').value, hrefs: [...document.querySelectorAll('#rrRows a.nq-link')].map(a => a.getAttribute('href')) })); assert(st.value === q, 'search field shows ' + JSON.stringify(st.value) + ', want ' + JSON.stringify(q) + ' ' + when); @@ -337,8 +337,18 @@ async function main() { // the same document, not a reload, and must bring the search back. const reachUrl = page.url(); const doc = await page.evaluate(() => ({ len: history.length, doc: window.__rrDoc })); + assert(doc.doc, 'document token missing before Back'); for (let round = 1; round <= 2; round++) { - await settleBoard(page, () => page.goBack()); + // A reload can destroy the page context under settleBoard. If Back failed, + // check which document the page shows once it has loaded: a different + // __rrDoc token means Back reloaded instead of traversing history. + await settleBoard(page, () => page.goBack()).catch(async e => { + await page.waitForLoadState('domcontentloaded'); + if (!(await page.evaluate(d => window.__rrDoc === d, doc.doc))) { + throw new Error('Back ' + round + ' reloaded the page instead of traversing history (' + e.message + ')'); + } + throw e; + }); const after = await page.evaluate(() => ({ len: history.length, doc: window.__rrDoc })); assert(after.doc === doc.doc && after.len === doc.len, 'Back ' + round + ' reloaded the page or changed history (' + JSON.stringify(after) + ' vs ' + JSON.stringify(doc) + ')'); await searchShown('after Back ' + round);