Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion public/packets.js
Original file line number Diff line number Diff line change
Expand Up @@ -2502,8 +2502,12 @@
const fNodeDrop = document.getElementById('fNodeDropdown');
fNode.value = filters.nodeName || '';
let nodeActiveIdx = -1;
// Bumped per search and on pick, so a slower response for an older query
// can't replace the list or reopen it after a selection (#595).
let nodeSearchSeq = 0;
fNode.addEventListener('input', debounce(async (e) => {
const q = e.target.value.trim();
const seq = ++nodeSearchSeq;
nodeActiveIdx = -1;
fNode.setAttribute('aria-activedescendant', '');
if (!q) {
Expand All @@ -2515,6 +2519,7 @@
try {
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
const data = await resp.json();
if (seq !== nodeSearchSeq) return;
const nodes = data.nodes || [];
if (nodes.length === 0) { fNodeDrop.classList.add('hidden'); fNode.setAttribute('aria-expanded', 'false'); return; }
fNodeDrop.innerHTML = nodes.map((n, i) =>
Expand All @@ -2523,14 +2528,18 @@
fNodeDrop.classList.remove('hidden');
fNode.setAttribute('aria-expanded', 'true');
fNodeDrop.querySelectorAll('.node-filter-option').forEach(opt => {
opt.addEventListener('click', () => {
// mousedown, not click: preventDefault keeps focus in the input, so
// the blur timer can't hide the list during a slow press (#595).
opt.addEventListener('mousedown', (ev) => {
ev.preventDefault();
selectNodeOption(opt);
});
});
} catch {}
}, 250));

function selectNodeOption(opt) {
nodeSearchSeq++;
filters.node = opt.dataset.key;
filters.nodeName = opt.dataset.name;
fNode.value = opt.dataset.name;
Expand Down
8 changes: 8 additions & 0 deletions scripts/e2e-shards.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
{
"version": 1,
"suites": [
{
"file": "test-issue-595-packets-filter-races-e2e.js",
"shard": 1,
"env": { "BASE_URL": "$BASE_URL" },
"output": "e2e",
"meanSeconds": 0,
"measurementNote": "New suite; #595 Packets node filter pick (slow press, late search response) and observer filter re-fetch with stale-response guard. Held responses via route interception, no sleeps. About 15s locally; no CI duration measured yet. Assigned to shard 1."
},
{
"file": "test-issue-262-mymesh-observer-e2e.js",
"shard": 1,
Expand Down
185 changes: 185 additions & 0 deletions test-issue-595-packets-filter-races-e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* E2E (#595): Packets page filter races.
*
* Node filter: picking an autocomplete suggestion must apply the filter even
* when the press is slow (the input's blur handler hides the list 200 ms after
* focus leaves) or when a search response rebuilds the list mid-press.
*
* Observer filter: ticking an observer must re-fetch /api/packets with
* observer=, and a slow response for an earlier selection must not overwrite
* the table for the later one.
*
* Responses are held with route interception and released explicitly; the
* only waits are on conditions, never on a fixed sleep for the outcome.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-595-packets-filter-races-e2e.js
*/
'use strict';
const { chromium } = require('playwright');

const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
// Rejects instead of hanging when the awaited request never happens.
function within(promise, ms, what) {
return Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error('no ' + what + ' within ' + ms + ' ms')), ms))]);
}
function deferred() {
let resolve; const promise = new Promise(r => { resolve = r; });
return { promise, resolve };
}

async function openPackets(browserOrCtx) {
// A fresh context per page: the observer filter persists in localStorage.
const ctx = browserOrCtx.newContext ? await browserOrCtx.newContext() : browserOrCtx;
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#fNode');
await page.waitForSelector('#pktBody tr[data-hash]', { state: 'attached' });
return page;
}

// Holds the first /api/nodes/search response after arm() until release().
async function holdSearch(page) {
const gate = deferred(); const inflight = deferred();
let armed = false;
await page.route(u => u.pathname === '/api/nodes/search', async route => {
if (!armed) return route.continue();
armed = false; inflight.resolve();
await gate.promise;
await route.continue();
});
return { arm() { armed = true; }, inflight: inflight.promise, release: gate.resolve };
}

async function suggestion(page, q) {
await page.fill('#fNode', '');
await page.type('#fNode', q);
await page.waitForSelector('#fNodeDropdown:not(.hidden) .node-filter-option');
return page.locator('#fNodeDropdown .node-filter-option').first();
}

async function press(page, opt, duringPress) {
const box = await opt.boundingBox();
assert(box, 'suggestion must be visible');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
if (duringPress) await duringPress();
await page.mouse.up();
}

async function assertPicked(page, name, key) {
try {
await page.waitForFunction(k => location.hash.includes('node=' + k), key, { timeout: 3000 });
} catch (_) { throw new Error('URL has no node=' + key.slice(0, 8) + ', got ' + page.url()); }
const v = await page.inputValue('#fNode');
assert(v === name, 'input must show "' + name + '", got "' + v + '"');
}

(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
console.log('Packets filter races E2E (#595) against ' + BASE);

const res = await ctx.request.get(BASE + '/api/nodes?limit=50');
assert(res.ok(), 'fixture node request must succeed');
const named = ((await res.json()).nodes || []).find(n => n.name && n.name.length >= 4 && n.public_key);
assert(named, 'fixture must contain a named node');
const query = named.name.slice(0, 3);

await step('node filter: a normal click applies the pick', async () => {
const page = await openPackets(browser);
const opt = await suggestion(page, query);
const name = await opt.getAttribute('data-name'), key = await opt.getAttribute('data-key');
await opt.click();
await assertPicked(page, name, key);
await page.close();
});

await step('node filter: a press held past the blur delay still applies the pick', async () => {
const page = await openPackets(browser);
const opt = await suggestion(page, query);
const name = await opt.getAttribute('data-name'), key = await opt.getAttribute('data-key');
// Hold until the blur timer would have hidden the list, observed as a
// condition on the page rather than slept for.
await press(page, opt, () => page.evaluate(() => new Promise(r => setTimeout(r, 350))));
await assertPicked(page, name, key);
await page.close();
});

await step('node filter: a late search response does not drop the pick or reopen the list', async () => {
const page = await openPackets(browser);
const hold = await holdSearch(page);
const opt = await suggestion(page, query);
const name = await opt.getAttribute('data-name'), key = await opt.getAttribute('data-key');
hold.arm();
await page.type('#fNode', named.name.charAt(3));
await within(hold.inflight, 5000, 'in-flight node search');
await press(page, opt);
await assertPicked(page, name, key);
hold.release();
// The held response resolves after the pick; wait until it has been
// consumed by the page (a following search round-trip) then check.
await page.evaluate(() => fetch('/api/nodes/search?q=zz').then(r => r.text()));
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const reopened = await page.$('#fNodeDropdown:not(.hidden)');
assert(!reopened, 'late response for the older query reopened the list after the pick');
await page.close();
});

// --- Observer filter ---
const obsRes = await ctx.request.get(BASE + '/api/observers');
const observers = ((await obsRes.json()).observers || []);
assert(observers.length >= 1, 'fixture must contain an observer');

await step('observer filter: ticking an observer re-fetches with observer=', async () => {
const page = await openPackets(browser);
await page.click('#observerTrigger');
const box = page.locator('#observerMenu input[data-obs-id]:not([data-obs-id="__all__"])').first();
const id = await box.getAttribute('data-obs-id');
const req = page.waitForRequest(r => {
const u = new URL(r.url());
return u.pathname === '/api/packets' && (u.searchParams.get('observer') || '').split(',').includes(id);
}, { timeout: 5000 }).catch(() => null);
await box.check();
assert(await req, 'ticking observer ' + id + ' issued no /api/packets request with observer=');
await page.close();
});

await step('observer filter: a slow response for an earlier selection does not overwrite the later one', async () => {
const page = await openPackets(browser);
const allRows = await page.locator('#pktBody tr[data-hash], #pktBody tr[data-id]').count();
const gate = deferred(); const inflight = deferred();
let held = false;
await page.route(u => u.pathname === '/api/packets', async route => {
const o = new URL(route.request().url()).searchParams.get('observer');
if (o && !held) { held = true; inflight.resolve(); await gate.promise; }
await route.continue();
});
await page.click('#observerTrigger');
const box = page.locator('#observerMenu input[data-obs-id]:not([data-obs-id="__all__"])').first();
await box.check();
await within(inflight.promise, 5000, 'held /api/packets request with observer=');
await page.locator('#observerMenu input[data-obs-id="__all__"]').check();
gate.resolve();
// After the late response lands, the table must still reflect "All".
await page.evaluate(() => fetch('/api/healthz').then(r => r.text()));
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const after = await page.locator('#pktBody tr[data-hash], #pktBody tr[data-id]').count();
assert(after === allRows, 'table shows ' + after + ' rows, expected the unfiltered ' + allRows);
await page.close();
});

await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' passed' + (failed ? ', ' + failed + ' failed' : ''));
process.exit(failed ? 1 : 0);
})().catch(e => { console.error(e); process.exit(2); });
Loading