/** * E2E test (#1639): observers table at #/observers must be sortable. * * Same fix-pattern as #679 (nodes table) — wire TableSort with * numeric / time column hints. Clicking a column header MUST reorder * tbody rows. * * Round-1 test additions (PR #1641): * - last_packet_at column reorder (regression guard for the round-0 * data-type=date → numeric fix; without this the one-line fix is * unguarded). * - strict monotonic non-increasing assertion (no more .some(changed), * which silently passes even when order is mostly wrong). * - toggle (second-click ascending) on packet_count. * - second-column string sort (Name) to exercise localeCompare. * - cells are addressed via data-testid (added in observers.js for * testability) — finding #8 dropped the broader data-col-key on * s, and data-testid is the less-invasive replacement. * * Usage: BASE_URL=http://localhost:13581 node test-issue-1639-observers-sort-e2e.js */ const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:3000'; async function test(name, fn) { try { await fn(); console.log(` \u2705 ${name}`); } catch (err) { console.log(` \u274c ${name}: ${err.message}`); process.exit(1); } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } // finding #5: strict monotonic non-increasing across ALL rows function assertNonIncreasing(arr, label) { for (let i = 0; i < arr.length - 1; i++) { // NaN slots are sorted last by the comparator — accept them at the tail. const a = arr[i], b = arr[i + 1]; if (!Number.isFinite(a)) continue; // NaN tail if (!Number.isFinite(b)) continue; if (!(a >= b)) { throw new Error(`${label}: not non-increasing at idx ${i}: ${a} < ${b}; full=${JSON.stringify(arr)}`); } } } function assertNonDecreasing(arr, label) { for (let i = 0; i < arr.length - 1; i++) { const a = arr[i], b = arr[i + 1]; if (!Number.isFinite(a)) continue; if (!Number.isFinite(b)) continue; if (!(a <= b)) { throw new Error(`${label}: not non-decreasing at idx ${i}: ${a} > ${b}; full=${JSON.stringify(arr)}`); } } } async function gotoObservers(page) { await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => { try { localStorage.removeItem('meshcore-observers-sort'); } catch (_) {} }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#obsTable', { timeout: 10000 }); await page.waitForSelector('#obsTable tbody tr', { timeout: 10000 }); } async function readNumeric(page, testid) { return await page.$$eval(`#obsTable tbody tr td[data-testid="${testid}"]`, (tds) => tds.map((td) => { const dv = td.getAttribute('data-value'); const raw = dv != null && dv !== '' ? dv : td.textContent.replace(/[^0-9.-]/g, ''); return Number(raw); })); } async function readStrings(page, testid) { return await page.$$eval(`#obsTable tbody tr td[data-testid="${testid}"]`, (tds) => tds.map((td) => td.getAttribute('data-value') || '')); } async function run() { 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({ viewport: { width: 1400, height: 900 } }); const page = await ctx.newPage(); page.setDefaultTimeout(15000); console.log(`\nRunning #1639 observers-sort E2E tests against ${BASE}\n`); await test('observers thead has data-sort-key + data-type on numeric columns', async () => { await gotoObservers(page); const totalPktsTh = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); assert(totalPktsTh, 'Total Packets must carry data-sort-key="packet_count"'); const type = await totalPktsTh.getAttribute('data-type'); assert(type === 'numeric', `Total Packets must have data-type="numeric", got "${type}"`); }); await test('clicking Total Packets header reorders rows numerically (desc)', async () => { await gotoObservers(page); const before = await readNumeric(page, 'obs-cell-packet-count'); assert(before.length >= 2, `need >=2 rows, got ${before.length}`); const th = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); assert(th, 'Total Packets not found'); await th.click(); await page.waitForTimeout(300); const after = await readNumeric(page, 'obs-cell-packet-count'); assert(after.length === before.length, `row count changed`); // finding #5: strict non-increasing across ALL rows, not just first/last assertNonIncreasing(after, 'packet_count desc'); }); // finding #13: toggle (second click on the same header → ascending) await test('second click on Total Packets header toggles to ascending', async () => { await gotoObservers(page); const th = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); await th.click(); // desc await page.waitForTimeout(200); await th.click(); // asc await page.waitForTimeout(300); const after = await readNumeric(page, 'obs-cell-packet-count'); assert(after.length >= 2, 'need >=2 rows'); assertNonDecreasing(after, 'packet_count asc (after toggle)'); }); // finding #4: regression guard for round-0 data-type=date → numeric fix await test('clicking Last Packet header reorders rows by timestamp (desc)', async () => { await gotoObservers(page); const th = await page.$('#obsTable thead th[data-sort-key="last_packet_at"]'); assert(th, 'Last Packet not found'); const type = await th.getAttribute('data-type'); assert(type === 'numeric', `Last Packet must have data-type="numeric" (round-0 fix), got "${type}"`); await th.click(); await page.waitForTimeout(300); const after = await readNumeric(page, 'obs-cell-last-packet'); assert(after.length >= 2, `need >=2 rows`); assertNonIncreasing(after, 'last_packet_at desc'); }); // finding #13: second-column test — Name string sort via localeCompare await test('clicking Name header sorts alphabetically (string localeCompare)', async () => { await gotoObservers(page); const th = await page.$('#obsTable thead th[data-sort-key="name"]'); assert(th, 'Name not found'); await th.click(); await page.waitForTimeout(300); const after = await readStrings(page, 'obs-cell-name'); assert(after.length >= 2, `need >=2 rows`); // Default direction for text columns is asc — sorted A→Z for (let i = 0; i < after.length - 1; i++) { if (after[i] === '' || after[i + 1] === '') continue; assert(after[i].localeCompare(after[i + 1]) <= 0, `Name asc broken at idx ${i}: ${JSON.stringify(after[i])} > ${JSON.stringify(after[i + 1])}`); } }); await browser.close(); console.log('\n✅ all #1639 sort tests passed\n'); } run().catch((e) => { console.error(e); process.exit(1); });