mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 17:57:43 +00:00
Resize handles now use their static table header as the containing block. Already-positioned headers, including sticky Nodes headers, retain their existing positioning. Fixes #2217. Validation: assertion-failing RED commit followed by the fix; all standalone frontend suites and the unchanged server suite pass. The browser suite passes 48 checks covering desktop/mobile bounds, real dragging, saved widths and sticky headers. The browser regression waits for the initial theme render before measuring. The fix adds one positioning check per header during setup, with no new requests, dependencies or settings. Common ingestor validation is covered by the separate #2272 test-stability change.
268 lines
13 KiB
JavaScript
268 lines
13 KiB
JavaScript
/**
|
|
* E2E (#1056): Fluid table columns + "+N hidden" pill.
|
|
*
|
|
* Boots Chromium against a local corescope-server and verifies that the
|
|
* primary tables (Packets, Nodes, Observers) collapse priority-tagged
|
|
* columns at narrow viewports, render a "+N hidden" pill in the header
|
|
* showing the count, and that clicking the pill reveals the hidden columns.
|
|
*
|
|
* Tested viewports: 768, 1080, 1440 (parent task: 768/1080/1440/1920).
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js
|
|
*/
|
|
'use strict';
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
|
|
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 PAGES = [
|
|
{ hash: '#/packets', tableSel: '#pktTable', name: 'packets' },
|
|
{ hash: '#/nodes', tableSel: '#nodesTable', name: 'nodes' },
|
|
{ hash: '#/observers', tableSel: '#obsTable', name: 'observers' },
|
|
];
|
|
|
|
const VIEWPORTS = [
|
|
{ w: 768, h: 900, expectHidden: true },
|
|
{ w: 1080, h: 900, expectHidden: true },
|
|
{ w: 1440, h: 900, expectHidden: false }, // wide enough — no hide expected (or 0)
|
|
{ w: 1920, h: 900, expectHidden: false }, // AC #5: also exercise 1920px
|
|
];
|
|
|
|
// Per-(page, viewport) override map. After #1415 the packets table's
|
|
// column priorities were rewritten to the locked spec — at 1080px (>1024)
|
|
// every packets column is visible (data-priority 3 columns reveal above
|
|
// 1024px, priority 5 above 768px). The /nodes and /observers tables still
|
|
// match the original #1056 contract. Default falls through to VIEWPORTS.
|
|
function expectHidden(pageName, w) {
|
|
if (pageName === 'packets' && w >= 1080) return false;
|
|
const vp = VIEWPORTS.find(v => v.w === w);
|
|
return vp ? vp.expectHidden : false;
|
|
}
|
|
|
|
async function waitForAnalyticsTables(page) {
|
|
// The initial theme-refresh replaces the Nodes tab's tables. Wait for that
|
|
// lifecycle event and the handles on the resulting render before measuring.
|
|
await page.waitForFunction(() => window.__columnResizeThemeReady &&
|
|
document.querySelector('#analyticsContent .analytics-table .col-resize-handle'));
|
|
}
|
|
|
|
(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=== #1056 fluid tables E2E against ${BASE} ===`);
|
|
|
|
for (const width of [1250, 375]) {
|
|
await step(`#2217: analytics resize handles stay on their headers at ${width}px`, async () => {
|
|
const ctx = await browser.newContext({ viewport: { width, height: 900 } });
|
|
try {
|
|
await ctx.addInitScript(() => {
|
|
window.addEventListener('theme-refresh', () => { window.__columnResizeThemeReady = true; }, { once: true });
|
|
});
|
|
const page = await ctx.newPage();
|
|
await page.goto(BASE + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' });
|
|
await waitForAnalyticsTables(page);
|
|
const geometry = await page.evaluate(() => ({
|
|
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
handles: Array.from(document.querySelectorAll('#analyticsContent .col-resize-handle')).map(handle => {
|
|
const header = handle.parentElement;
|
|
const h = header.getBoundingClientRect(), r = handle.getBoundingClientRect();
|
|
return { anchored: handle.offsetParent === header, height: r.height,
|
|
top: r.top - h.top, edge: r.left + r.width / 2 - h.right };
|
|
}),
|
|
}));
|
|
assert(geometry.handles.length > 0, 'expected analytics resize handles');
|
|
assert(geometry.handles.every(h => h.anchored), 'resize handle has the page as its containing block');
|
|
assert(geometry.handles.every(h => h.height > 0 && Math.abs(h.top) <= 2 && Math.abs(h.edge) <= 2),
|
|
'resize handles must follow the header edge: ' + JSON.stringify(geometry.handles));
|
|
assert(geometry.overflow <= 1, `analytics page overflows by ${geometry.overflow}px`);
|
|
|
|
if (width === 1250) {
|
|
const selector = '#analyticsContent .analytics-table thead th:first-child';
|
|
const header = page.locator(selector).first();
|
|
await header.scrollIntoViewIfNeeded();
|
|
const before = await header.evaluate(th => {
|
|
const r = th.getBoundingClientRect();
|
|
return { width: th.offsetWidth, x: r.right - 2, y: r.top + r.height / 2 };
|
|
});
|
|
await page.mouse.move(before.x, before.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(before.x + 40, before.y, { steps: 4 });
|
|
await page.mouse.up();
|
|
const dragged = await header.evaluate(th => ({
|
|
width: th.offsetWidth,
|
|
percent: th.offsetWidth / th.closest('table').offsetWidth * 100,
|
|
saved: JSON.parse(localStorage.getItem('meshcore-analytics-nodes-0-col-widths')),
|
|
}));
|
|
assert(dragged.width > before.width + 20, 'dragging must resize the analytics column');
|
|
assert(Array.isArray(dragged.saved), 'drag must save column widths');
|
|
assert(Math.abs(dragged.saved[0] - dragged.percent) < 1, 'saved percentage must match dragged width');
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForAnalyticsTables(page);
|
|
const restored = await header.evaluate(th => ({
|
|
percent: th.offsetWidth / th.closest('table').offsetWidth * 100,
|
|
anchored: th.querySelector('.col-resize-handle').offsetParent === th,
|
|
}));
|
|
assert(Math.abs(restored.percent - dragged.saved[0]) < 1, 'reload must restore the saved column width');
|
|
assert(restored.anchored, 'restored widths must still anchor the resize handle to its header');
|
|
}
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
await step('#2079: responsive columns preserve full-width status and spacer rows', async () => {
|
|
const ctx = await browser.newContext({ viewport: { width: 375, height: 900 } });
|
|
try {
|
|
const page = await ctx.newPage();
|
|
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => !!window.TableResponsive);
|
|
const result = await page.evaluate(() => {
|
|
const table = document.createElement('table');
|
|
table.id = 'responsiveSpanFixture';
|
|
table.className = 'data-table';
|
|
table.innerHTML = '<thead><tr><th>Kept</th><th data-priority="5">Hidden</th>' +
|
|
'<th data-priority="5">Hidden</th><th>Kept</th></tr></thead>' +
|
|
'<tbody><tr data-normal><td>A</td><td>B</td><td>C</td><td>D</td></tr>' +
|
|
'<tr data-status="empty"><td colspan="4">No packets found</td></tr>' +
|
|
'<tr data-status="error"><td colspan="4">Failed to load packets</td></tr>' +
|
|
'<tr data-status="spacer"><td colspan="4" style="height:48px;padding:0"></td></tr>' +
|
|
'<tr data-partial><td>A</td><td colspan="2">Partial span</td><td>D</td></tr></tbody>';
|
|
document.getElementById('app').appendChild(table);
|
|
window.TableResponsive.apply(table);
|
|
return {
|
|
normalHidden: Array.from(table.querySelector('[data-normal]').children)
|
|
.map(cell => getComputedStyle(cell).display === 'none'),
|
|
partialHidden: getComputedStyle(table.querySelector('[data-partial] td[colspan]')).display === 'none',
|
|
status: Array.from(table.querySelectorAll('[data-status]')).map(row => ({
|
|
name: row.dataset.status,
|
|
visible: getComputedStyle(row.firstElementChild).display !== 'none',
|
|
height: row.firstElementChild.getBoundingClientRect().height,
|
|
})),
|
|
};
|
|
});
|
|
assert(JSON.stringify(result.normalHidden) === '[false,true,true,false]',
|
|
'ordinary priority cells must still hide: ' + JSON.stringify(result));
|
|
assert(result.partialHidden, 'a partial span must retain column-index mapping');
|
|
for (const row of result.status) {
|
|
assert(row.visible && row.height > 0, row.name + ' full-width row must remain visible');
|
|
}
|
|
assert(result.status.find(row => row.name === 'spacer').height >= 48,
|
|
'virtual-scroll spacer must retain its height');
|
|
await page.locator('#responsiveSpanFixture .col-hidden-pill').click();
|
|
assert(await page.locator('#responsiveSpanFixture .col-hidden').count() === 0,
|
|
'reveal must still restore ordinary and partial-span cells');
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
});
|
|
|
|
for (const vp of VIEWPORTS) {
|
|
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
|
|
|
for (const p of PAGES) {
|
|
const tag = `${p.name}@${vp.w}`;
|
|
|
|
await step(`${tag}: page renders`, async () => {
|
|
await page.goto(BASE + '/' + p.hash, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector(p.tableSel, { timeout: 8000 });
|
|
// give responsive logic a tick
|
|
await page.waitForTimeout(300);
|
|
});
|
|
|
|
await step(`${tag}: no horizontal table scroll`, async () => {
|
|
const overflow = await page.evaluate((sel) => {
|
|
const t = document.querySelector(sel);
|
|
if (!t) return { ok: false, reason: 'no table' };
|
|
// Either the table itself or a wrapper must not horizontally overflow
|
|
// its container at this viewport.
|
|
const wrap = t.closest('.table-fluid-wrap, .obs-table-scroll, .table-scroll-wrap') || t.parentElement;
|
|
return {
|
|
tableW: t.scrollWidth,
|
|
wrapW: wrap.clientWidth,
|
|
// Allow a few px tolerance for sub-pixel rounding / scrollbar gutter.
|
|
ok: t.scrollWidth <= wrap.clientWidth + 8,
|
|
};
|
|
}, p.tableSel);
|
|
assert(overflow.ok, `table overflows: tableW=${overflow.tableW} wrapW=${overflow.wrapW}`);
|
|
});
|
|
|
|
if (p.name === 'nodes') {
|
|
await step(`${tag}: resize handles preserve sticky headers (#2217)`, async () => {
|
|
const headers = await page.$$eval('#nodesTable th', ths => ths
|
|
.filter(th => th.offsetParent !== null && th.querySelector('.col-resize-handle'))
|
|
.map(th => ({ position: getComputedStyle(th).position,
|
|
anchored: th.querySelector('.col-resize-handle').offsetParent === th })));
|
|
assert(headers.length > 0, 'expected visible Nodes resize handles');
|
|
assert(headers.every(h => h.position === 'sticky' && h.anchored),
|
|
'Nodes headers must remain sticky and contain their handles: ' + JSON.stringify(headers));
|
|
});
|
|
}
|
|
|
|
await step(`${tag}: +N hidden pill state matches hidden columns`, async () => {
|
|
const info = await page.evaluate((sel) => {
|
|
const t = document.querySelector(sel);
|
|
if (!t) return { ok: false, reason: 'no table' };
|
|
const heads = Array.from(t.querySelectorAll('thead th'));
|
|
const hiddenHeads = heads.filter(h => h.classList.contains('col-hidden'));
|
|
const pill = t.querySelector('.col-hidden-pill');
|
|
return {
|
|
hiddenCount: hiddenHeads.length,
|
|
hasPill: !!pill,
|
|
pillText: pill ? pill.textContent.trim() : '',
|
|
pillVisible: pill ? pill.offsetParent !== null : false,
|
|
};
|
|
}, p.tableSel);
|
|
|
|
if (expectHidden(p.name, vp.w)) {
|
|
assert(info.hiddenCount >= 1, `expected ≥1 hidden col at ${vp.w}px, got ${info.hiddenCount}`);
|
|
assert(info.hasPill && info.pillVisible, `expected visible +N pill at ${vp.w}px`);
|
|
assert(/\+\d+/.test(info.pillText), `pill text "${info.pillText}" missing +N marker`);
|
|
const m = info.pillText.match(/\+(\d+)/);
|
|
const n = m ? parseInt(m[1], 10) : -1;
|
|
assert(n === info.hiddenCount, `pill says +${n} but ${info.hiddenCount} columns are hidden`);
|
|
} else {
|
|
// wide: no hidden cols, pill should be absent or hidden
|
|
if (info.hasPill) assert(!info.pillVisible || /\+0/.test(info.pillText), `expected no/zero pill at ${vp.w}px, got "${info.pillText}"`);
|
|
}
|
|
});
|
|
|
|
if (expectHidden(p.name, vp.w)) {
|
|
await step(`${tag}: clicking pill reveals hidden columns`, async () => {
|
|
// Click pill
|
|
const pillSel = `${p.tableSel} .col-hidden-pill`;
|
|
await page.click(pillSel);
|
|
await page.waitForTimeout(150);
|
|
const after = await page.evaluate((sel) => {
|
|
const t = document.querySelector(sel);
|
|
const heads = Array.from(t.querySelectorAll('thead th'));
|
|
return heads.filter(h => h.classList.contains('col-hidden')).length;
|
|
}, p.tableSel);
|
|
assert(after === 0, `expected 0 hidden cols after pill click, got ${after}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
await ctx.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
console.log(`\n=== #1056 fluid tables E2E: ${passed} passed, ${failed} failed ===`);
|
|
process.exit(failed ? 1 : 0);
|
|
})();
|